Skip to content

Commit 12c1d7e

Browse files
committed
feat: enhance DataView support for parameter binding and fix RETURNING clause metadata handling
1 parent 6b7222a commit 12c1d7e

File tree

3 files changed

+146
-102
lines changed

3 files changed

+146
-102
lines changed

CLAUDE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,35 @@ Aggregate functions presented unique challenges due to N-API constraints in SQLi
297297

298298
For detailed implementation history and lessons learned, see: `doc/archive/aggregate-function-implementation-summary.md`
299299

300+
### N-API ArrayBufferView Type Checking
301+
302+
**CRITICAL**: N-API's `IsBuffer()` returns `true` for ALL ArrayBufferView types, not just Node.js Buffer.
303+
304+
This is because the underlying N-API implementation uses `IsArrayBufferView()`:
305+
- `Buffer` → IsBuffer: ✓, IsTypedArray: ✓, IsDataView: ✗
306+
- `Uint8Array` → IsBuffer: ✓, IsTypedArray: ✓, IsDataView: ✗
307+
- `DataView` → IsBuffer: ✓, IsTypedArray: ✗, IsDataView: ✓
308+
309+
**The Problem**: If you check `IsBuffer()` before `IsDataView()`, DataView objects get caught by the Buffer check, but `Napi::Buffer<uint8_t>::As()` returns garbage (length=0, data=null) for DataView.
310+
311+
**The Solution**: Always check `IsDataView()` BEFORE `IsBuffer()` in type-checking chains:
312+
313+
```cpp
314+
// CORRECT ORDER:
315+
if (param.IsDataView()) {
316+
// Handle DataView specifically
317+
} else if (param.IsBuffer()) {
318+
// Handles Buffer and TypedArray (both work with Buffer cast)
319+
}
320+
321+
// WRONG ORDER (DataView gets mishandled):
322+
if (param.IsBuffer()) {
323+
// DataView matches here but Buffer::As() fails!
324+
} else if (param.IsDataView()) {
325+
// Never reached for DataView
326+
}
327+
```
328+
300329
### Async Cleanup Anti-Patterns
301330

302331
**IMPORTANT**: The following approaches are NOT valid solutions for async cleanup issues:

src/sqlite_impl.cpp

Lines changed: 34 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,9 +1400,15 @@ Napi::Value StatementSync::Run(const Napi::CallbackInfo &info) {
14001400
return env.Undefined();
14011401
}
14021402

1403-
int result = sqlite3_step(statement_);
1403+
// Execute the statement
1404+
sqlite3_step(statement_);
1405+
// Reset immediately after step to ensure sqlite3_changes() returns
1406+
// correct value. This fixes an issue where RETURNING queries would
1407+
// report changes: 0 on the first call.
1408+
// See: https://github.com/nodejs/node/issues/57344
1409+
int result = sqlite3_reset(statement_);
14041410

1405-
if (result != SQLITE_DONE && result != SQLITE_ROW) {
1411+
if (result != SQLITE_OK) {
14061412
std::string error = sqlite3_errmsg(database_->connection());
14071413
ThrowEnhancedSqliteErrorWithDB(env, database_, database_->connection(),
14081414
result, error);
@@ -1943,7 +1949,27 @@ void StatementSync::BindSingleParameter(int param_index, Napi::Value param) {
19431949
} else if (param.IsBoolean()) {
19441950
sqlite3_bind_int(statement_, param_index,
19451951
param.As<Napi::Boolean>().Value() ? 1 : 0);
1952+
} else if (param.IsDataView()) {
1953+
// IMPORTANT: Check DataView BEFORE IsBuffer() because N-API's IsBuffer()
1954+
// returns true for ALL ArrayBufferViews (including DataView), but
1955+
// Buffer::As() doesn't work correctly for DataView (returns length=0).
1956+
Napi::DataView dataView = param.As<Napi::DataView>();
1957+
Napi::ArrayBuffer arrayBuffer = dataView.ArrayBuffer();
1958+
size_t byteOffset = dataView.ByteOffset();
1959+
size_t byteLength = dataView.ByteLength();
1960+
1961+
if (arrayBuffer.Data() != nullptr && byteLength > 0) {
1962+
const uint8_t *data =
1963+
static_cast<const uint8_t *>(arrayBuffer.Data()) + byteOffset;
1964+
sqlite3_bind_blob(statement_, param_index, data,
1965+
static_cast<int>(byteLength), SQLITE_TRANSIENT);
1966+
} else {
1967+
sqlite3_bind_null(statement_, param_index);
1968+
}
19461969
} else if (param.IsBuffer()) {
1970+
// Handles Buffer and TypedArray (both are ArrayBufferViews that work
1971+
// correctly with Buffer cast - Buffer::Data() handles byte offsets
1972+
// internally)
19471973
Napi::Buffer<uint8_t> buffer = param.As<Napi::Buffer<uint8_t>>();
19481974
sqlite3_bind_blob(statement_, param_index, buffer.Data(),
19491975
static_cast<int>(buffer.Length()), SQLITE_TRANSIENT);
@@ -1960,99 +1986,13 @@ void StatementSync::BindSingleParameter(int param_index, Napi::Value param) {
19601986
} else {
19611987
sqlite3_bind_null(statement_, param_index);
19621988
}
1963-
} else if (param.IsTypedArray() || param.IsDataView()) {
1964-
// Handle TypedArray and DataView as binary data (both are ArrayBufferView
1965-
// types)
1966-
if (param.IsTypedArray()) {
1967-
// Handle TypedArray using native methods
1968-
Napi::TypedArray typedArray = param.As<Napi::TypedArray>();
1969-
Napi::ArrayBuffer arrayBuffer = typedArray.ArrayBuffer();
1970-
1971-
if (!arrayBuffer.IsUndefined() && arrayBuffer.Data() != nullptr) {
1972-
const uint8_t *data =
1973-
static_cast<const uint8_t *>(arrayBuffer.Data()) +
1974-
typedArray.ByteOffset();
1975-
sqlite3_bind_blob(statement_, param_index, data,
1976-
static_cast<int>(typedArray.ByteLength()),
1977-
SQLITE_TRANSIENT);
1978-
} else {
1979-
sqlite3_bind_null(statement_, param_index);
1980-
}
1981-
} else {
1982-
// Handle DataView using Buffer.from(dataView.buffer, offset, length)
1983-
// conversion
1984-
try {
1985-
Napi::Env env = param.Env();
1986-
Napi::Object dataViewObj = param.As<Napi::Object>();
1987-
1988-
// Get DataView properties via JavaScript
1989-
Napi::Value bufferValue = dataViewObj.Get("buffer");
1990-
Napi::Value byteOffsetValue = dataViewObj.Get("byteOffset");
1991-
Napi::Value byteLengthValue = dataViewObj.Get("byteLength");
1992-
1993-
if (bufferValue.IsArrayBuffer() && byteOffsetValue.IsNumber() &&
1994-
byteLengthValue.IsNumber()) {
1995-
// Create Buffer from underlying ArrayBuffer with proper offset and
1996-
// length
1997-
Napi::Function bufferFrom = env.Global()
1998-
.Get("Buffer")
1999-
.As<Napi::Object>()
2000-
.Get("from")
2001-
.As<Napi::Function>();
2002-
Napi::Value buffer = bufferFrom.Call(
2003-
{bufferValue, byteOffsetValue, byteLengthValue});
2004-
2005-
if (buffer.IsBuffer()) {
2006-
Napi::Buffer<uint8_t> buf = buffer.As<Napi::Buffer<uint8_t>>();
2007-
if (buf.Length() > 0) {
2008-
sqlite3_bind_blob(statement_, param_index, buf.Data(),
2009-
static_cast<int>(buf.Length()),
2010-
SQLITE_TRANSIENT);
2011-
} else {
2012-
sqlite3_bind_null(statement_, param_index);
2013-
}
2014-
} else {
2015-
sqlite3_bind_null(statement_, param_index);
2016-
}
2017-
} else {
2018-
sqlite3_bind_null(statement_, param_index);
2019-
}
2020-
} catch (const Napi::Error &e) {
2021-
// Re-throw Napi errors (preserves error message)
2022-
throw;
2023-
} catch (const std::exception &e) {
2024-
// If conversion fails, bind as NULL
2025-
sqlite3_bind_null(statement_, param_index);
2026-
}
2027-
}
20281989
} else if (param.IsObject()) {
2029-
// Handle any other object that might be a DataView that wasn't caught
2030-
// This is a fallback for objects that aren't explicitly handled above
2031-
try {
2032-
// Try to see if this is a DataView by checking for DataView properties
2033-
Napi::Object obj = param.As<Napi::Object>();
2034-
Napi::Value constructorName =
2035-
obj.Get("constructor").As<Napi::Object>().Get("name");
2036-
2037-
if (constructorName.IsString() &&
2038-
constructorName.As<Napi::String>().Utf8Value() == "DataView") {
2039-
// TEMPORARY: Just bind DataView as NULL to test basic branching
2040-
sqlite3_bind_null(statement_, param_index);
2041-
} else {
2042-
// Objects and arrays should throw error like Node.js does
2043-
throw Napi::Error::New(
2044-
Env(), "Provided value cannot be bound to SQLite parameter " +
2045-
std::to_string(param_index) + ".");
2046-
}
2047-
} catch (const Napi::Error &e) {
2048-
// Re-throw Napi errors (preserves error message)
2049-
throw;
2050-
} catch (const std::exception &e) {
2051-
// If any property access fails, treat as non-bindable object
2052-
throw Napi::Error::New(
2053-
Env(), "Provided value cannot be bound to SQLite parameter " +
2054-
std::to_string(param_index) + ".");
2055-
}
1990+
// Objects and arrays cannot be bound to SQLite parameters (same as
1991+
// Node.js behavior). Note: DataView, Buffer, TypedArray, and ArrayBuffer
1992+
// are handled above and don't reach this branch.
1993+
throw Napi::Error::New(
1994+
Env(), "Provided value cannot be bound to SQLite parameter " +
1995+
std::to_string(param_index) + ".");
20561996
} else {
20571997
// For any other type, throw error like Node.js does
20581998
throw Napi::Error::New(

test/parameter-binding.test.ts

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,13 @@ describe("Parameter Binding Tests", () => {
225225
});
226226
});
227227

228-
test.skip("DataView binding - Not Currently Supported", () => {
229-
// NOTE: DataView parameter binding is not currently supported.
230-
// Use Buffer instead for binary data binding.
228+
test("DataView binding", () => {
231229
const stmt = db.prepare(
232230
"INSERT INTO test_params (value_blob) VALUES (?)",
233231
);
234232

235-
// Create a buffer with various data types
236-
const buffer = new ArrayBuffer(20); // Increased size to accommodate all data
233+
// Test case 1: Basic DataView with various data types
234+
const buffer = new ArrayBuffer(20);
237235
const view = new DataView(buffer);
238236

239237
// Write different values
@@ -243,7 +241,7 @@ describe("Parameter Binding Tests", () => {
243241
view.setUint16(4, 65535, true);
244242
view.setInt32(6, -2147483648, true);
245243
view.setUint32(10, 4294967295, true);
246-
view.setFloat32(16, Math.PI, true); // Fixed offset to not overflow
244+
view.setFloat32(16, Math.PI, true);
247245

248246
const result = stmt.run(view);
249247
const row = db
@@ -263,6 +261,42 @@ describe("Parameter Binding Tests", () => {
263261
expect(resultView.getFloat32(16, true)).toBeCloseTo(Math.PI);
264262
});
265263

264+
test("DataView with offset and length", () => {
265+
const stmt = db.prepare(
266+
"INSERT INTO test_params (value_blob) VALUES (?)",
267+
);
268+
269+
// Create a DataView that only uses part of the underlying buffer
270+
const bytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
271+
const offsetView = new DataView(bytes.buffer, 1, 3); // "ell"
272+
273+
const result = stmt.run(offsetView);
274+
const row = db
275+
.prepare("SELECT value_blob FROM test_params WHERE id = ?")
276+
.get(result.lastInsertRowid);
277+
278+
expect(row.value_blob).toBeInstanceOf(Uint8Array);
279+
expect(row.value_blob.length).toBe(3);
280+
expect(Array.from(row.value_blob)).toEqual([0x65, 0x6c, 0x6c]); // "ell"
281+
});
282+
283+
test("Empty DataView", () => {
284+
const stmt = db.prepare(
285+
"INSERT INTO test_params (value_blob) VALUES (?)",
286+
);
287+
288+
const emptyBuffer = new ArrayBuffer(0);
289+
const emptyView = new DataView(emptyBuffer);
290+
291+
const result = stmt.run(emptyView);
292+
const row = db
293+
.prepare("SELECT value_blob FROM test_params WHERE id = ?")
294+
.get(result.lastInsertRowid);
295+
296+
// Empty DataView should bind as NULL
297+
expect(row.value_blob).toBeNull();
298+
});
299+
266300
test("multiple parameters of different types", () => {
267301
const stmt = db.prepare(`
268302
INSERT INTO test_params (value_null, value_int, value_real, value_text, value_blob, value_bigint)
@@ -479,8 +513,7 @@ describe("Parameter Binding Tests", () => {
479513
{ value: Buffer.from([1, 2, 3]), expectedType: "blob" },
480514
{ value: new Uint8Array([4, 5, 6]), expectedType: "blob" },
481515
{ value: new Float32Array([1.1, 2.2]), expectedType: "blob" },
482-
// DataView not supported yet - N-API limitation
483-
// { value: new DataView(new ArrayBuffer(4)), expectedType: "blob" },
516+
{ value: new DataView(new ArrayBuffer(4)), expectedType: "blob" },
484517
];
485518

486519
testCases.forEach(({ value, expectedType }) => {
@@ -491,4 +524,46 @@ describe("Parameter Binding Tests", () => {
491524
});
492525
});
493526
});
527+
528+
describe("RETURNING clause metadata", () => {
529+
// Regression test for: https://github.com/nodejs/node/issues/57344
530+
// This test ensures that run() returns correct metadata when using RETURNING.
531+
// The bug was that sqlite3_changes() was called before sqlite3_reset(),
532+
// causing incorrect change counts on the first call.
533+
test("returns correct metadata when using RETURNING clause", () => {
534+
db.exec(
535+
"CREATE TABLE returning_test (key INTEGER PRIMARY KEY, val INTEGER NOT NULL)",
536+
);
537+
const stmt = db.prepare(
538+
"INSERT INTO returning_test (key, val) VALUES ($k, $v) RETURNING key",
539+
);
540+
541+
// First insert - this was returning changes: 0 before the fix
542+
const result1 = stmt.run({ $k: 1, $v: 10 });
543+
expect(result1).toEqual({ changes: 1, lastInsertRowid: 1 });
544+
545+
// Subsequent inserts should also work correctly
546+
const result2 = stmt.run({ $k: 2, $v: 20 });
547+
expect(result2).toEqual({ changes: 1, lastInsertRowid: 2 });
548+
549+
const result3 = stmt.run({ $k: 3, $v: 30 });
550+
expect(result3).toEqual({ changes: 1, lastInsertRowid: 3 });
551+
});
552+
553+
test("returns correct metadata when reusing statement with RETURNING", () => {
554+
db.exec(
555+
"CREATE TABLE returning_reuse_test (id INTEGER PRIMARY KEY, value TEXT)",
556+
);
557+
const stmt = db.prepare(
558+
"INSERT INTO returning_reuse_test (value) VALUES (?) RETURNING id",
559+
);
560+
561+
// Run multiple times with different values
562+
for (let i = 1; i <= 5; i++) {
563+
const result = stmt.run(`value${i}`);
564+
expect(result.changes).toBe(1);
565+
expect(result.lastInsertRowid).toBe(i);
566+
}
567+
});
568+
});
494569
});

0 commit comments

Comments
 (0)