diff --git a/doc.md b/doc.md index 8fad1b6..986a5ee 100644 --- a/doc.md +++ b/doc.md @@ -310,6 +310,18 @@ for (const row of stmt) { } ``` +Use `iter()` to pass bind parameters to the same kind of iteration: + +```ts +for (const row of stmt.iter("bar", "baz")) { + console.log(row); +} +``` + +Either way, the statement is reset as soon as the loop ends, including when you +`break` out of it or an exception leaves it, so a partly consumed iteration does +not hold a read transaction open on the connection. + ## Transactions To start a transaction, use the `transaction()` method. This method takes a diff --git a/src/statement.ts b/src/statement.ts index 264dccd..95ea632 100644 --- a/src/statement.ts +++ b/src/statement.ts @@ -831,22 +831,34 @@ export class Statement> { return readCstr(sqlite3_expanded_sql(this.#handle)!); } - /** Iterate over resultant rows from query. */ + /** + * Iterate over resultant rows from query. + * + * The statement is reset when the iterator finishes, whether it was + * exhausted, left early (`break`, `return`), or exited by an exception, so + * an abandoned iteration does not keep a read transaction open on the + * connection. + */ *iter(...params: RestBindParameters): IterableIterator { this.#begin(); this.#bindAll(params); const getRowObject = this.getRowObject(); const int64 = this.int64 ?? this.db.int64; const parseJson = this.parseJson ?? this.db.parseJson; - let status = sqlite3_step(this.#handle); - while (status === SQLITE3_ROW) { - yield getRowObject(this.#handle, int64, parseJson); - status = sqlite3_step(this.#handle); - } - if (status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + try { + let status = sqlite3_step(this.#handle); + while (status === SQLITE3_ROW) { + yield getRowObject(this.#handle, int64, parseJson); + status = sqlite3_step(this.#handle); + } + if (status !== SQLITE3_DONE) { + unwrap(status, this.db.unsafeHandle); + } + } finally { + // Ignore the result: after a failed step, reset returns that same error, + // which `unwrap` above has already thrown. + sqlite3_reset(this.#handle); } - sqlite3_reset(this.#handle); } [Symbol.iterator](): IterableIterator { diff --git a/test/test.ts b/test/test.ts index 405fde5..a5ffbf5 100644 --- a/test/test.ts +++ b/test/test.ts @@ -339,6 +339,48 @@ Deno.test("sqlite", async (t) => { assertEquals(db.sql`select ${"1; DROP TABLE"}`, [{ "?": "1; DROP TABLE" }]); }); + await t.step("iterate (early exit resets the statement)", () => { + db.exec("create table iter_reset (id integer primary key)"); + db.exec("insert into iter_reset (id) values (1), (2), (3)"); + const stmt = db.prepare("select id from iter_reset"); + + // A statement left mid-iteration keeps a read cursor open on the table, + // which makes `drop table` fail with "database table is locked". + const dropTable = (): void => { + db.exec("drop table iter_reset"); + db.exec("create table iter_reset (id integer primary key)"); + db.exec("insert into iter_reset (id) values (1), (2), (3)"); + }; + + for (const _ of stmt.iter()) break; + dropTable(); + + for (const _ of stmt) break; + dropTable(); + + assertThrows( + () => { + for (const _ of stmt.iter()) throw new Error("abandon"); + }, + Error, + "abandon", + ); + dropTable(); + + // The open cursor also pins a WAL read snapshot, so once another + // connection commits, this connection cannot begin a write transaction. + for (const _ of stmt.iter()) break; + const other = new Database(DB_URL); + other.exec("insert into iter_reset (id) values (4)"); + other.close(); + db.exec("begin immediate"); + db.exec("rollback"); + + assertEquals(stmt.all(), [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]); + stmt.finalize(); + db.exec("drop table iter_reset"); + }); + await t.step("more than 32-bit int", () => { const value = 978307200000; db.exec(