Procházet zdrojové kódy

v0.3.0: incremental BLOB I/O and online backup API

Eric Streit před 2 týdny
rodič
revize
6a31f839f4
9 změnil soubory, kde provedl 328 přidání a 40 odebrání
  1. 1 1
      Makefile
  2. 6 6
      README.md
  3. 34 20
      docs/summary-v0.3.0.md
  4. 5 1
      lib/SQLiteUtils.def
  5. 32 1
      lib/SQLiteUtils.mod
  6. 61 7
      showcases/showcase_all.mod
  7. 43 3
      src/SQLite.def
  8. 1 1
      tests/run_tests.sh
  9. 145 0
      tests/test_blob.mod

+ 1 - 1
Makefile

@@ -23,7 +23,7 @@ OBJS    := $(OBJDIR)/SQLiteUtils.o
 
 INCLUDES := -I$(SRC_DIR) -I$(LIB_DIR)
 
-TESTS    := test_version test_open test_exec test_prepare test_func
+TESTS    := test_version test_open test_exec test_prepare test_func test_blob
 EXAMPLES := version hello_db
 SHOWCASES := showcase_all
 

+ 6 - 6
README.md

@@ -15,11 +15,11 @@ Starter set, verified with gm2 16.0.1 + SQLite 3.46.1 (system
 
 | Module | C header | Contents |
 |---|---|---|
-| `SQLite` | `<sqlite3.h>` (`sqlite-master/src/sqlite.h.in`) | version, open/close, exec, errcode/errmsg, prepare/step/finalize/reset, bind_*, column_*, changes/rowid, busy_timeout, limits, custom functions (create_function, value_*, result_*, aggregate/userdata/auxdata context) |
-| `SQLiteUtils` (`lib/`) | — | `CStrToM2()` + `ErrMsg()`/`ErrStr()`/`LibVersionStr()`, `StaticDestr()`/`TransientDestr()`, `BindTextCopy()`/`BindBlobCopy()`, `ValueText()`/`ResultTextCopy()`, `ExecSimple()`/`ExecWithErr()` |
+| `SQLite` | `<sqlite3.h>` (`sqlite-master/src/sqlite.h.in`) | version, open/close, exec, errcode/errmsg, prepare/step/finalize/reset, bind_*, column_*, changes/rowid, busy_timeout, limits, custom functions (create_function, value_*, result_*, aggregate/userdata/auxdata context), incremental BLOB I/O, online backup |
+| `SQLiteUtils` (`lib/`) | — | `CStrToM2()` + `ErrMsg()`/`ErrStr()`/`LibVersionStr()`, `StaticDestr()`/`TransientDestr()`, `BindTextCopy()`/`BindBlobCopy()`, `ValueText()`/`ResultTextCopy()`, `BackupToFile()`, `ExecSimple()`/`ExecWithErr()` |
 
-Window functions (`create_window_function`), blobs (`blob_open`),
-backups and sessions come next.
+Window functions (`create_window_function`), collations and sessions
+come next.
 
 ## Layout
 
@@ -109,5 +109,5 @@ prepare, binds, columns, close) and prints each result.
 ## Roadmap
 
 1. Window functions (`create_window_function`) + collations.
-2. Incremental BLOB I/O (`blob_open/read/write`) + backup API.
-3. Extended result codes as named constants + `sqlite3_get_table` wrapper.
+2. Extended result codes as named constants + `sqlite3_get_table` wrapper.
+3. Sessions/changesets (`ext/session`).

+ 34 - 20
docs/summary-v0.2.0.md → docs/summary-v0.3.0.md

@@ -1,8 +1,8 @@
-# m2SQLITE summary — v0.2.0 (2026-09-24)
+# m2SQLITE summary — v0.3.0 (2026-09-24)
 
 SQLite bindings for GNU Modula-2 (`gm2` 16.0.1, SQLite 3.46.1 system
 `libsqlite3.so.0`, Linux; API reference `sqlite-master/` 3.54.0).
-All builds verified with `make`; tests 5/5 green, showcase exits 0.
+All builds verified with `make`; tests 6/6 green, showcase exits 0.
 
 ## v0.1.0 — core binding + showcase
 
@@ -38,22 +38,30 @@ All builds verified with `make`; tests 5/5 green, showcase exits 0.
   text/text64/value/zeroblob/zeroblob64), context helpers
   (`aggregate_context`, `user_data`, `context_db_handle`,
   `get/set_auxdata`); UTF-encoding and function-flag constants
-  (UTF8/UTF16 variants, DETERMINISTIC/DIRECTONLY/SUBTYPE/INNOCUOUS/
-  RESULT_SUBTYPE/SELFORDER1). 16-bit and window-function APIs
-  deliberately omitted (noted in README roadmap).
-- `lib/SQLiteUtils` — `ValueText` (value handle into CHAR array),
-  `ResultTextCopy` (TRANSIENT text result)
-- `tests/test_func` — 17 registered functions: scalar double/text/
-  int64/blob-echo/type-probe/null-passthrough, sized results
-  (zeroblob/64, blob64, text64), error paths (exact codes 1/18/7
-  verified), `msum` aggregate with `aggregate_context` state,
-  `user_data` call counting and `context_db_handle` check, auxdata
-  set/get round trip, `create_function_v2` + DETERMINISTIC flag
-- `showcases/showcase_all` — new section [8]: `m2tax` scalar and
-  `m2total` aggregate over a table (close moves to [9])
-- Key result: module-level Modula-2 procedures ARE C-compatible
-  function pointers (proven by spike against real `libsqlite3`,
-  then by the suite); `NIL` accepted for unused callbacks
+- `lib/SQLiteUtils` — `ValueText`, `ResultTextCopy`
+- `tests/test_func` — 17 registered functions: scalars, sized results,
+  error paths (exact codes 1/18/7), `msum` aggregate with shared state,
+  `user_data` counting, `context_db_handle` check, auxdata round trip
+- `showcases/showcase_all` — section [8]: `m2tax` scalar, `m2total`
+  aggregate
+
+## v0.3.0 — BLOB I/O and online backup
+
+- `src/SQLite.def` — handle APIs (+11 procedures): `BlobHandle` and
+  `BackupHandle` opaque types, `blob_open`/`reopen`/`close`/`bytes`/
+  `read`/`write`, `backup_init` (returns the handle or NIL),
+  `backup_step`/`finish`/`remaining`/`pagecount`
+- `lib/SQLiteUtils` — `BackupToFile` one-call copy of a live database
+  to a file path (open, init, page loop with Busy/Locked retry bound,
+  finish, close; step/finish errors propagate)
+- `tests/test_blob` — 256-byte write/read round trip with offset,
+  `reopen` across rows, read-only write refusal (code 8), close
+  relaying the pending handle error (documented behavior, asserted),
+  direct backup (`step(-1)` to Done, remaining/pagecount) verified by
+  row count, `BackupToFile` to `/tmp` verified and unlinked
+- `showcases/showcase_all` — section [10]: 64-byte blob write/read,
+  file backup, reopen-and-count, temp-file removal (close moves
+  to [11])
 
 ## GM2 FFI rules learned (encoded in code)
 
@@ -78,8 +86,14 @@ All builds verified with `make`; tests 5/5 green, showcase exits 0.
    arrays with a `VAL` overlay onto `POINTER TO ARRAY OF ADDRESS`.
 9. This SQLite build rejects the `AS t(v)` column-alias form in
    `FROM (VALUES ...)`; use bare `column1` names in tests.
+10. `ORD` returns `CARDINAL`: mix with `INTEGER` only via `VAL`, and
+    compare `HIGH()` (also `CARDINAL`) the same way. `blob_close`
+    relays a handle's pending operation error while still releasing
+    it — expect codes, not just `Ok`.
+11. `libc.unlink` takes `ADDRESS` (`ADR` of a NUL buffer) and its
+    `[INTEGER]` result may be assigned or dropped as a statement.
 
 ## Next
 
-Window functions (`create_window_function`) + collations,
-incremental BLOB I/O + backup API, extended result-code constants.
+Window functions (`create_window_function`) + collations, extended
+result-code constants + `get_table` wrapper, sessions/changesets.

+ 5 - 1
lib/SQLiteUtils.def

@@ -10,13 +10,15 @@ DEFINITION MODULE SQLiteUtils ;
 *)
 
 FROM SYSTEM IMPORT ADDRESS;
-FROM SQLite IMPORT DbHandle, StmtHandle, ContextHandle, ValueHandle;
+FROM SQLite IMPORT DbHandle, StmtHandle, ContextHandle, ValueHandle,
+   BackupHandle;
 
 EXPORT UNQUALIFIED
    CStrToM2, ErrMsg, ErrStr, LibVersionStr,
    StaticDestr, TransientDestr,
    BindTextCopy, BindBlobCopy,
    ValueText, ResultTextCopy,
+   BackupToFile,
    ExecSimple, ExecWithErr;
 
 PROCEDURE CStrToM2 (src: ADDRESS; VAR dst: ARRAY OF CHAR);
@@ -31,6 +33,8 @@ PROCEDURE BindBlobCopy (stmt: StmtHandle; idx: INTEGER;
                         data: ADDRESS; n: INTEGER) : INTEGER;
 PROCEDURE ValueText (v: ValueHandle; VAR buf: ARRAY OF CHAR);
 PROCEDURE ResultTextCopy (ctx: ContextHandle; str: ARRAY OF CHAR);
+PROCEDURE BackupToFile (srcDb: DbHandle;
+                        filename: ARRAY OF CHAR) : INTEGER;
 PROCEDURE ExecSimple (db: DbHandle; sql: ARRAY OF CHAR) : INTEGER;
 PROCEDURE ExecWithErr (db: DbHandle; sql: ARRAY OF CHAR;
                        VAR buf: ARRAY OF CHAR) : INTEGER;

+ 32 - 1
lib/SQLiteUtils.mod

@@ -2,10 +2,14 @@ IMPLEMENTATION MODULE SQLiteUtils ;
 
 FROM SYSTEM IMPORT ADDRESS, ADR;
 FROM SQLite IMPORT DbHandle, StmtHandle, ContextHandle, ValueHandle,
+   BackupHandle,
+   SQLiteOk, SQLiteDone, SQLiteBusy, SQLiteLocked,
    sqlite3_libversion, sqlite3_errmsg, sqlite3_errstr,
+   sqlite3_open, sqlite3_close, sqlite3_errcode,
    sqlite3_exec, sqlite3_free,
    sqlite3_bind_text, sqlite3_bind_blob,
-   sqlite3_value_text, sqlite3_result_text;
+   sqlite3_value_text, sqlite3_result_text,
+   sqlite3_backup_init, sqlite3_backup_step, sqlite3_backup_finish;
 FROM libc IMPORT strlen, strncpy;
 
 PROCEDURE CStrToM2 (src: ADDRESS; VAR dst: ARRAY OF CHAR);
@@ -66,6 +70,33 @@ BEGIN
    sqlite3_result_text(ctx, str, -1, TransientDestr())
 END ResultTextCopy;
 
+PROCEDURE BackupToFile (srcDb: DbHandle;
+                        filename: ARRAY OF CHAR) : INTEGER;
+VAR dst: DbHandle; bk: BackupHandle; rc, rc2, tries: INTEGER;
+BEGIN
+   rc := sqlite3_open(filename, dst);
+   IF rc # SQLiteOk THEN RETURN rc END;
+   bk := sqlite3_backup_init(dst, "main", srcDb, "main");
+   IF bk = NIL THEN
+      rc := sqlite3_errcode(dst);
+      rc2 := sqlite3_close(dst);
+      RETURN rc
+   END;
+   tries := 0;
+   LOOP
+      rc := sqlite3_backup_step(bk, 50);
+      IF (rc # SQLiteOk) AND (rc # SQLiteBusy)
+         AND (rc # SQLiteLocked) THEN EXIT END;
+      INC(tries);
+      IF tries > 100000 THEN rc := SQLiteBusy; EXIT END
+   END;
+   IF rc = SQLiteDone THEN rc := SQLiteOk END;
+   rc2 := sqlite3_backup_finish(bk);
+   IF rc = SQLiteOk THEN rc := rc2 END;
+   rc2 := sqlite3_close(dst);
+   RETURN rc
+END BackupToFile;
+
 PROCEDURE ExecSimple (db: DbHandle; sql: ARRAY OF CHAR) : INTEGER;
 BEGIN
    RETURN sqlite3_exec(db, sql, NIL, NIL, NIL)

+ 61 - 7
showcases/showcase_all.mod

@@ -12,11 +12,13 @@ MODULE showcase_all ;
    6. all column kinds, metadata, busy/reset/rebind, transactions
    7. expanded sql, zeroblob, static destructor, close_v2
    8. custom functions: scalar tax plus aggregate total
-   9. close
+   9. blob read/write plus file backup
+   10. close
 *)
 
 FROM SYSTEM IMPORT ADDRESS, ADR;
 FROM SQLite IMPORT DbHandle, StmtHandle, ContextHandle, ValueHandle,
+   BlobHandle,
    SQLiteOk, SQLiteRow, SQLiteDone,
    SQLiteInteger, SQLiteFloat, SQLiteText, SQLiteBlob, SQLiteNull,
    SQLiteOpenReadWrite, SQLiteOpenCreate,
@@ -47,19 +49,22 @@ FROM SQLite IMPORT DbHandle, StmtHandle, ContextHandle, ValueHandle,
    sqlite3_create_function,
    sqlite3_value_int64, sqlite3_value_double,
    sqlite3_result_double, sqlite3_result_int64,
-   sqlite3_aggregate_context;
+   sqlite3_aggregate_context,
+   sqlite3_blob_open, sqlite3_blob_close,
+   sqlite3_blob_bytes, sqlite3_blob_read, sqlite3_blob_write;
 FROM SQLiteUtils IMPORT CStrToM2, ErrMsg, ErrStr, LibVersionStr,
    StaticDestr, TransientDestr,
-   BindTextCopy, BindBlobCopy, ExecSimple, ExecWithErr;
-FROM libc IMPORT printf, strncpy;
+   BindTextCopy, BindBlobCopy, ExecSimple, ExecWithErr, BackupToFile;
+FROM libc IMPORT printf, strncpy, unlink;
 
 TYPE
    ArgVec = POINTER TO ARRAY [0..255] OF ADDRESS;
    TotalPtr = POINTER TO LONGINT;
 
 VAR
-   db, db2: DbHandle;
+   db, db2, db3: DbHandle;
    stmt: StmtHandle;
+   bh: BlobHandle;
    rc, i, ncols, ndata, busy, ro: INTEGER;
    li: LONGINT;
    d: REAL;
@@ -69,8 +74,24 @@ VAR
    blobIn: ARRAY [0..3] OF CHAR;
    staticBlob: ARRAY [0..3] OF CHAR;
    out: ARRAY [0..15] OF CHAR;
+   wpat: ARRAY [0..15] OF CHAR;
+   rback: ARRAY [0..63] OF CHAR;
    showInt, showFloat, showText, showBlob, showNull: INTEGER;
 
+PROCEDURE RemoveFile (path: ARRAY OF CHAR);
+VAR fbuf: ARRAY [0..255] OF CHAR; n: INTEGER; urc: INTEGER;
+BEGIN
+   n := 0;
+   WHILE (VAL(CARDINAL, n) <= HIGH(fbuf))
+         AND (VAL(CARDINAL, n) <= HIGH(path))
+         AND (path[n] # 0C) DO
+      fbuf[n] := path[n];
+      INC(n)
+   END;
+   IF VAL(CARDINAL, n) <= HIGH(fbuf) THEN fbuf[n] := 0C END;
+   urc := unlink(ADR(fbuf))
+END RemoveFile;
+
 PROCEDURE fail (what: ARRAY OF CHAR);
 VAR e: ARRAY [0..255] OF CHAR;
 BEGIN
@@ -340,8 +361,41 @@ BEGIN
    IF li # 60 THEN fail("fx total") END;
    check(sqlite3_finalize(stmt), "fin fx");
 
-   (* 9. close paths *)
-   section(9, "close");
+   (* 10. blob I/O and backup *)
+   section(10, "blob and backup");
+   check(ExecSimple(db, "CREATE TABLE raw(data BLOB);"), "raw create");
+   check(ExecSimple(db, "INSERT INTO raw VALUES(zeroblob(64));"),
+         "raw seed");
+   check(sqlite3_blob_open(db, "main", "raw", "data",
+                           sqlite3_last_insert_rowid(db), 1, bh),
+         "raw open");
+   printf("blob size %d\n", sqlite3_blob_bytes(bh));
+   IF sqlite3_blob_bytes(bh) # 64 THEN fail("raw size") END;
+   FOR i := 0 TO 15 DO
+      wpat[i] := CHR(ORD('0') + VAL(CARDINAL, i MOD 10))
+   END;
+   check(sqlite3_blob_write(bh, ADR(wpat), 16, 0), "raw write");
+   check(sqlite3_blob_read(bh, ADR(rback), 16, 0), "raw read");
+   FOR i := 0 TO 15 DO
+      IF rback[i] # wpat[i] THEN fail("raw roundtrip") END
+   END;
+   printf("blob ok\n");
+   check(sqlite3_blob_close(bh), "raw close");
+   check(BackupToFile(db, "/tmp/m2sqlite_showcase_backup.db"),
+         "show backup");
+   check(sqlite3_open("/tmp/m2sqlite_showcase_backup.db", db3),
+         "open backup");
+   check(sqlite3_prepare_v2(db3, "SELECT COUNT(*) FROM raw;",
+                            -1, stmt, NIL), "prep backup");
+   IF sqlite3_step(stmt) # SQLiteRow THEN fail("step backup") END;
+   printf("backup rows %d\n", sqlite3_column_int(stmt, 0));
+   IF sqlite3_column_int(stmt, 0) # 1 THEN fail("backup count") END;
+   check(sqlite3_finalize(stmt), "fin backup");
+   check(sqlite3_close(db3), "close backup");
+   RemoveFile("/tmp/m2sqlite_showcase_backup.db");
+
+   (* 11. close paths *)
+   section(11, "close");
    check(sqlite3_close_v2(db2), "close_v2");
    check(sqlite3_close(db), "close");
    printf("PASS showcase_all\n")

+ 43 - 3
src/SQLite.def

@@ -35,7 +35,7 @@ DEFINITION MODULE FOR "C" SQLite ;
 FROM SYSTEM IMPORT ADDRESS;
 
 EXPORT UNQUALIFIED
-   DbHandle, StmtHandle,
+   DbHandle, StmtHandle, BlobHandle, BackupHandle,
 
    SQLiteOk, SQLiteError, SQLiteInternal, SQLitePerm, SQLiteAbort,
    SQLiteBusy, SQLiteLocked, SQLiteNoMem, SQLiteReadOnly,
@@ -89,7 +89,6 @@ EXPORT UNQUALIFIED
    sqlite3_column_type, sqlite3_column_name, sqlite3_column_decltype,
 
    ContextHandle, ValueHandle, XFunc, XStep, XFinal, XDestroy,
-
    SQLiteUtf8, SQLiteUtf16LE, SQLiteUtf16BE, SQLiteUtf16, SQLiteUtfAny,
    SQLiteDeterministic, SQLiteDirectOnly, SQLiteSubtype,
    SQLiteInnocuous, SQLiteResultSubtype, SQLiteSelfOrder1,
@@ -105,7 +104,12 @@ EXPORT UNQUALIFIED
    sqlite3_result_text, sqlite3_result_text64, sqlite3_result_value,
    sqlite3_result_zeroblob, sqlite3_result_zeroblob64,
    sqlite3_aggregate_context, sqlite3_user_data,
-   sqlite3_context_db_handle, sqlite3_get_auxdata, sqlite3_set_auxdata;
+   sqlite3_context_db_handle, sqlite3_get_auxdata, sqlite3_set_auxdata,
+
+   sqlite3_blob_open, sqlite3_blob_reopen, sqlite3_blob_close,
+   sqlite3_blob_bytes, sqlite3_blob_read, sqlite3_blob_write,
+   sqlite3_backup_init, sqlite3_backup_step, sqlite3_backup_finish,
+   sqlite3_backup_remaining, sqlite3_backup_pagecount;
 
 
 TYPE
@@ -113,6 +117,8 @@ TYPE
    StmtHandle = ADDRESS;   (* sqlite3_stmt handle *)
    ContextHandle = ADDRESS;   (* sqlite3_context handle *)
    ValueHandle   = ADDRESS;   (* sqlite3_value handle *)
+   BlobHandle    = ADDRESS;   (* sqlite3_blob handle *)
+   BackupHandle  = ADDRESS;   (* sqlite3_backup handle *)
 
    (* Callback types for application-defined SQL functions.
       Declare module-level procedures of these types and pass them
@@ -485,4 +491,38 @@ PROCEDURE sqlite3_get_auxdata (ctx: ContextHandle; idx: INTEGER) : ADDRESS;
 PROCEDURE sqlite3_set_auxdata (ctx: ContextHandle; idx: INTEGER;
                                p: ADDRESS; destr: XDestroy);
 
+(* Incremental BLOB I/O. Open a handle on the BLOB or TEXT value in
+   the given database, table and column at the row with the given
+   rowid. flags is 0 for read-only, nonzero for read-write. The
+   value must already exist, use zeroblob to reserve space. Reopen
+   moves the handle to another row of the same table. Offsets and
+   counts are bytes; short reads and writes fail. Close releases
+   the handle even when it reports an error. *)
+PROCEDURE sqlite3_blob_open (db: DbHandle;
+                             dbName, table, column: ARRAY OF CHAR;
+                             row: LONGINT; flags: INTEGER;
+                             VAR bh: BlobHandle) : INTEGER;
+PROCEDURE sqlite3_blob_reopen (bh: BlobHandle; row: LONGINT) : INTEGER;
+PROCEDURE sqlite3_blob_close (bh: BlobHandle) : INTEGER;
+PROCEDURE sqlite3_blob_bytes (bh: BlobHandle) : INTEGER;
+PROCEDURE sqlite3_blob_read (bh: BlobHandle; buf: ADDRESS;
+                             n, offset: INTEGER) : INTEGER;
+PROCEDURE sqlite3_blob_write (bh: BlobHandle; data: ADDRESS;
+                              n, offset: INTEGER) : INTEGER;
+
+(* Online backup. Init returns the backup handle or NIL on failure,
+   in which case the destination error code explains why. Step
+   copies pages, returning Done when finished and Ok while pages
+   remain; Busy and Locked mean retry later. Remaining and pagecount
+   report progress after each step. Finish releases the handle and
+   reports the first error seen, if any. See SQLiteUtils for a
+   one-call file backup built on these. *)
+PROCEDURE sqlite3_backup_init (dst: DbHandle; dstName: ARRAY OF CHAR;
+                               src: DbHandle;
+                               srcName: ARRAY OF CHAR) : BackupHandle;
+PROCEDURE sqlite3_backup_step (bk: BackupHandle; pages: INTEGER) : INTEGER;
+PROCEDURE sqlite3_backup_finish (bk: BackupHandle) : INTEGER;
+PROCEDURE sqlite3_backup_remaining (bk: BackupHandle) : INTEGER;
+PROCEDURE sqlite3_backup_pagecount (bk: BackupHandle) : INTEGER;
+
 END SQLite.

+ 1 - 1
tests/run_tests.sh

@@ -20,7 +20,7 @@ fi
 # shellcheck disable=SC2086
 $GM2 -I"$ROOT/src" -I"$ROOT/lib" $GM2FLAGS -c "$ROOT/lib/SQLiteUtils.mod" -o "$OBJ/SQLiteUtils.o"
 pass=0; fail=0
-for t in test_version test_open test_exec test_prepare test_func; do
+for t in test_version test_open test_exec test_prepare test_func test_blob; do
   echo "== $t =="
   # shellcheck disable=SC2086
   $GM2 -I"$ROOT/src" -I"$ROOT/lib" $GM2FLAGS "$ROOT/tests/$t.mod" \

+ 145 - 0
tests/test_blob.mod

@@ -0,0 +1,145 @@
+MODULE test_blob ;
+
+(*
+   m2SQLITE test: incremental BLOB I/O and the online backup API.
+   Covers open/read/write/reopen/bytes/close, the read-only write
+   error, direct backup paging, and the BackupToFile helper with
+   a temp file that is removed afterwards.
+*)
+
+FROM SYSTEM IMPORT ADDRESS, ADR;
+FROM SQLite IMPORT DbHandle, StmtHandle, BlobHandle, BackupHandle,
+   SQLiteOk, SQLiteRow, SQLiteDone, SQLiteReadOnly,
+   sqlite3_open, sqlite3_close,
+   sqlite3_prepare_v2, sqlite3_step, sqlite3_finalize,
+   sqlite3_last_insert_rowid,
+   sqlite3_column_int,
+   sqlite3_blob_open, sqlite3_blob_reopen, sqlite3_blob_close,
+   sqlite3_blob_bytes, sqlite3_blob_read, sqlite3_blob_write,
+   sqlite3_backup_init, sqlite3_backup_step, sqlite3_backup_finish,
+   sqlite3_backup_remaining, sqlite3_backup_pagecount;
+FROM SQLiteUtils IMPORT ErrMsg, ExecSimple, BackupToFile;
+FROM libc IMPORT printf, unlink;
+
+VAR
+   db, db2: DbHandle; stmt: StmtHandle;
+   bh, bh2: BlobHandle; bk: BackupHandle;
+   row1, row2: LONGINT;
+   wbuf: ARRAY [0..15] OF CHAR;
+   rbuf: ARRAY [0..255] OF CHAR;
+   path: ARRAY [0..63] OF CHAR;
+   i: INTEGER;
+
+PROCEDURE fail (what: ARRAY OF CHAR);
+VAR e: ARRAY [0..255] OF CHAR;
+BEGIN
+   ErrMsg(db, e);
+   printf("FAIL %s: %s\n", what, e);
+   HALT(1)
+END fail;
+
+PROCEDURE check (rc: INTEGER; what: ARRAY OF CHAR);
+BEGIN
+   IF rc # SQLiteOk THEN fail(what) END
+END check;
+
+PROCEDURE RemoveFile (name: ARRAY OF CHAR);
+VAR buf: ARRAY [0..255] OF CHAR; n: INTEGER; rc: INTEGER;
+BEGIN
+   n := 0;
+   WHILE (VAL(CARDINAL, n) <= HIGH(buf))
+         AND (VAL(CARDINAL, n) <= HIGH(name))
+         AND (name[n] # 0C) DO
+      buf[n] := name[n];
+      INC(n)
+   END;
+   IF VAL(CARDINAL, n) <= HIGH(buf) THEN buf[n] := 0C END;
+   rc := unlink(ADR(buf))
+END RemoveFile;
+
+PROCEDURE countFiles (h: DbHandle; what: ARRAY OF CHAR) : INTEGER;
+VAR st: StmtHandle; n, rc: INTEGER;
+BEGIN
+   rc := sqlite3_prepare_v2(h, "SELECT COUNT(*) FROM files;",
+                            -1, st, NIL);
+   IF rc # SQLiteOk THEN fail(what) END;
+   IF sqlite3_step(st) # SQLiteRow THEN fail(what) END;
+   n := sqlite3_column_int(st, 0);
+   IF sqlite3_finalize(st) # SQLiteOk THEN fail(what) END;
+   RETURN n
+END countFiles;
+
+BEGIN
+   FOR i := 0 TO 15 DO
+      wbuf[i] := CHR(ORD('A') + VAL(CARDINAL, i))
+   END;
+   check(sqlite3_open(":memory:", db), "open");
+   check(ExecSimple(db, "CREATE TABLE files(name TEXT, data BLOB);"),
+         "create");
+   check(ExecSimple(db, "INSERT INTO files VALUES('a', zeroblob(256));"),
+         "seed1");
+   row1 := sqlite3_last_insert_rowid(db);
+   check(ExecSimple(db, "INSERT INTO files VALUES('b', zeroblob(16));"),
+         "seed2");
+   row2 := sqlite3_last_insert_rowid(db);
+
+   (* read-write handle, size check, write, read back *)
+   check(sqlite3_blob_open(db, "main", "files", "data", row1, 1, bh),
+         "blob open");
+   IF sqlite3_blob_bytes(bh) # 256 THEN fail("blob bytes") END;
+   check(sqlite3_blob_write(bh, ADR(wbuf), 16, 10), "blob write");
+   check(sqlite3_blob_read(bh, ADR(rbuf), 16, 10), "blob read");
+   FOR i := 0 TO 15 DO
+      IF rbuf[i] # wbuf[i] THEN fail("blob roundtrip") END
+   END;
+   printf("blob roundtrip ok\n");
+
+   (* move the handle to the second row *)
+   check(sqlite3_blob_reopen(bh, row2), "blob reopen");
+   IF sqlite3_blob_bytes(bh) # 16 THEN fail("reopen bytes") END;
+   check(sqlite3_blob_close(bh), "blob close");
+
+   (* read-only handle must refuse writes *)
+   check(sqlite3_blob_open(db, "main", "files", "data", row1, 0, bh2),
+         "ro open");
+   IF sqlite3_blob_write(bh2, ADR(wbuf), 1, 0) # SQLiteReadOnly THEN
+      fail("ro write accepted")
+   END;
+   printf("readonly guard ok\n");
+   (* close relays the handle's pending write error, but the
+      handle is released regardless *)
+   i := sqlite3_blob_close(bh2);
+   printf("ro close rc %d\n", i);
+
+   (* direct backup API into a second memory database *)
+   check(sqlite3_open(":memory:", db2), "open dst");
+   bk := sqlite3_backup_init(db2, "main", db, "main");
+   IF bk = NIL THEN fail("backup init") END;
+   i := sqlite3_backup_step(bk, -1);
+   printf("backup step %d remaining %d pages %d\n",
+          i, sqlite3_backup_remaining(bk),
+          sqlite3_backup_pagecount(bk));
+   IF i # SQLiteDone THEN fail("backup step") END;
+   IF sqlite3_backup_remaining(bk) # 0 THEN fail("remaining") END;
+   IF sqlite3_backup_pagecount(bk) <= 0 THEN fail("pagecount") END;
+   check(sqlite3_backup_finish(bk), "backup finish");
+   IF countFiles(db2, "verify") # 2 THEN fail("backup content") END;
+   printf("backup copy ok\n");
+   check(sqlite3_close(db2), "close dst");
+
+   (* helper to a temp file, verified and removed *)
+   path[0] := '/'; path[1] := 't'; path[2] := 'm'; path[3] := 'p';
+   path[4] := '/'; path[5] := 'm'; path[6] := '2'; path[7] := 's';
+   path[8] := 'q'; path[9] := 'l'; path[10] := 'i'; path[11] := 't';
+   path[12] := 'e'; path[13] := '_'; path[14] := 'b'; path[15] := 'k';
+   path[16] := '.'; path[17] := 'd'; path[18] := 'b'; path[19] := 0C;
+   check(BackupToFile(db, path), "backup file");
+   check(sqlite3_open(path, db2), "open file");
+   IF countFiles(db2, "file content") # 2 THEN fail("file rows") END;
+   check(sqlite3_close(db2), "close file");
+   RemoveFile(path);
+   printf("backup file ok\n");
+
+   check(sqlite3_close(db), "close");
+   printf("PASS test_blob\n")
+END test_blob.