From bfc4a62258d3d9c14273515fce3d04be0f3d8fed Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 8 Aug 2026 16:19:08 +0200 Subject: [PATCH] Add typed SQLite BLOB support --- spago.lock | 9 +++++++++ spago.yaml | 1 + src/Yoga/SQLite/SQLite.js | 9 +++++++++ src/Yoga/SQLite/SQLite.purs | 11 +++++++++++ src/Yoga/SQLite/Schema.purs | 24 ++++++++++++++++++++++++ test/Main.purs | 21 ++++++++++++++++++++- test/TempDb.js | 3 +++ test/TempDb.purs | 5 +++++ 8 files changed, 82 insertions(+), 1 deletion(-) diff --git a/spago.lock b/spago.lock index 9943aad..fab9106 100644 --- a/spago.lock +++ b/spago.lock @@ -8,6 +8,9 @@ { "aff": ">=8.0.0 <9.0.0" }, + { + "arraybuffer-types": ">=3.0.2 <4.0.0" + }, { "arrays": ">=7.3.0 <8.0.0" }, @@ -140,6 +143,12 @@ "prelude" ] }, + "arraybuffer-types": { + "type": "registry", + "version": "3.0.2", + "integrity": "sha256-p05cJnSkyeoB7VHzMyc2Eb1RwUaB7Nl0sQSqEGNEUD8=", + "dependencies": [] + }, "arrays": { "type": "registry", "version": "7.3.0", diff --git a/spago.yaml b/spago.yaml index d0edfa4..c98338f 100644 --- a/spago.yaml +++ b/spago.yaml @@ -10,6 +10,7 @@ package: githubRepo: purescript-yoga-sqlite dependencies: - aff: ">=8.0.0 <9.0.0" + - arraybuffer-types: ">=3.0.2 <4.0.0" - arrays: ">=7.3.0 <8.0.0" - datetime: ">=6.1.0 <7.0.0" - effect: ">=4.0.0 <5.0.0" diff --git a/src/Yoga/SQLite/SQLite.js b/src/Yoga/SQLite/SQLite.js index 4aad82c..b24cf37 100644 --- a/src/Yoga/SQLite/SQLite.js +++ b/src/Yoga/SQLite/SQLite.js @@ -160,6 +160,15 @@ export const pingImpl = async (client) => { // Convert DateTime (JSDate) to ISO string for SQLite TEXT storage export const dateTimeToStringImpl = (jsDate) => jsDate.toISOString(); +// General SQLite BLOB conversions. Copy a sliced view so unrelated bytes from +// its backing buffer are never persisted. +export const uint8ArrayToArrayBufferImpl = (bytes) => + bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength + ? bytes.buffer + : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + +export const arrayBufferToUint8ArrayImpl = (buffer) => new Uint8Array(buffer); + // F32 vector <-> Array conversion for Turso vector columns export const f32VectorFromArrayImpl = (arr) => new Float32Array(arr).buffer; export const f32VectorToArrayImpl = (buf) => Array.from(new Float32Array(buf)); diff --git a/src/Yoga/SQLite/SQLite.purs b/src/Yoga/SQLite/SQLite.purs index 51ed2e3..307b5c1 100644 --- a/src/Yoga/SQLite/SQLite.purs +++ b/src/Yoga/SQLite/SQLite.purs @@ -2,6 +2,7 @@ module Yoga.SQLite.SQLite where import Prelude +import Data.ArrayBuffer.Types (ArrayBuffer, Uint8Array) import Data.DateTime (DateTime) import Data.Maybe (Maybe(..)) import Data.Newtype (class Newtype) @@ -18,6 +19,16 @@ import Promise (Promise) import Promise.Aff (toAffE) as Promise import Unsafe.Coerce (unsafeCoerce) +foreign import uint8ArrayToArrayBufferImpl :: Uint8Array -> ArrayBuffer + +uint8ArrayToArrayBuffer :: Uint8Array -> ArrayBuffer +uint8ArrayToArrayBuffer = uint8ArrayToArrayBufferImpl + +foreign import arrayBufferToUint8ArrayImpl :: ArrayBuffer -> Uint8Array + +arrayBufferToUint8Array :: ArrayBuffer -> Uint8Array +arrayBufferToUint8Array = arrayBufferToUint8ArrayImpl + -- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -- Opaque Foreign Types -- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/src/Yoga/SQLite/Schema.purs b/src/Yoga/SQLite/Schema.purs index b620702..5735164 100644 --- a/src/Yoga/SQLite/Schema.purs +++ b/src/Yoga/SQLite/Schema.purs @@ -4,6 +4,7 @@ import Prelude import Data.Array as Array import Data.Array (intercalate, mapWithIndex, foldl) +import Data.ArrayBuffer.Types (ArrayBuffer, Uint8Array) import Data.Date (Date, exactDate) import Data.Enum (toEnum, fromEnum) import Data.DateTime (DateTime(..), date, time) @@ -98,6 +99,26 @@ instance ReadForeign Json where s :: String <- readImpl f Json <$> parseJSON s +-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +-- Blob: general-purpose SQLite BLOB backed by an ArrayBuffer +-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +newtype Blob = Blob ArrayBuffer + +derive instance Newtype Blob _ + +blobFromUint8Array :: Uint8Array -> Blob +blobFromUint8Array = Blob <<< SQLite.uint8ArrayToArrayBuffer + +blobToUint8Array :: Blob -> Uint8Array +blobToUint8Array (Blob buffer) = SQLite.arrayBufferToUint8Array buffer + +instance ReadForeign Blob where + readImpl = pure <<< Blob <<< unsafeCoerce + +instance SQLite.ToSQLiteValue Blob where + toSQLiteValue (Blob buffer) = unsafeCoerce buffer + -- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -- SQLUUID: newtype over Data.UUID.UUID (stored as TEXT in SQLite) -- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -317,6 +338,9 @@ instance SQLiteTypeName SQLTime where instance SQLiteTypeName Json where sqliteTypeName _ = "TEXT" +instance SQLiteTypeName Blob where + sqliteTypeName _ = "BLOB" + instance IsSymbol dim => SQLiteTypeName (F32Vector dim) where sqliteTypeName _ = "F32_BLOB(" <> reflectSymbol (Proxy :: Proxy dim) <> ")" diff --git a/test/Main.purs b/test/Main.purs index 975f210..3af3b53 100644 --- a/test/Main.purs +++ b/test/Main.purs @@ -25,7 +25,7 @@ import Test.Spec.Reporter.Console (consoleReporter) import Test.Spec.Runner (runSpec) import Type.Function (type (#)) import Type.Proxy (Proxy(..)) -import Test.Sqlite.TempDb (mkTempDbUrl) +import Test.Sqlite.TempDb (mkTempDbUrl, testBytes, uint8ArrayValues) import Yoga.JSON (class ReadForeign, readImpl, unsafeStringify) import Yoga.SQLite.SQLite as SQLite import Yoga.SQLite.Schema @@ -108,6 +108,14 @@ type F64DocsTable = Table "f64docs" f64DocsTable :: Proxy F64DocsTable f64DocsTable = Proxy +type BlobsTable = Table "blobs" + ( id :: Int # PrimaryKey # AutoIncrement + , body :: Blob + ) + +blobsTable :: Proxy BlobsTable +blobsTable = Proxy + type RandomRowIdTable = Table "things" ( id :: Int # PrimaryKey # RandomRowId , name :: String @@ -479,6 +487,10 @@ spec = before setupConn do let result = createTableDDL @LinkedCodesTable result `shouldEqual` "CREATE TABLE linked_codes (code TEXT NOT NULL REFERENCES codes(code), id INTEGER PRIMARY KEY AUTOINCREMENT)" + it "derives BLOB columns from Blob" \_ -> do + let result = createTableDDL @BlobsTable + result `shouldEqual` "CREATE TABLE blobs (body BLOB NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT)" + describe "SQL builder output" do it "SELECT *" \_ -> do toSQL typedSelectAll `shouldEqual` "SELECT * FROM users" @@ -700,6 +712,13 @@ spec = before setupConn do let result = map (\r -> unF64Vector r.embedding) (rows :: Array { id :: Int, title :: String, embedding :: F64Vector "3" }) result `shouldEqual` [[1.0, 2.0, 3.0]] + it "round-trips a sliced Uint8Array as one BLOB" \conn -> do + SQLite.executeSimple (SQLite.SQL (createTableDDL @BlobsTable)) conn # void + runExecute conn {} (from blobsTable # insert { body: blobFromUint8Array testBytes }) # void + rows <- runQuery conn {} (from blobsTable # selectAll) + let values = map (uint8ArrayValues <<< blobToUint8Array <<< _.body) (rows :: Array { id :: Int, body :: Blob }) + values `shouldEqual` [ [ 0, 1, 2, 127, 128, 255 ] ] + it "columnTypes populated after query" \conn -> do SQLite.executeSimple (SQLite.SQL (createTableDDL @UsersTable)) conn # void runExecute conn {} (from usersTable # insert { name: "Alice", email: "a@b.com", age: Just 30 }) # void diff --git a/test/TempDb.js b/test/TempDb.js index 7035dea..5a734d3 100644 --- a/test/TempDb.js +++ b/test/TempDb.js @@ -2,6 +2,9 @@ import { mkdtempSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +const testBytesBacking = new Uint8Array([99, 0, 1, 2, 127, 128, 255, 99]); +export const testBytes = testBytesBacking.subarray(1, 7); +export const uint8ArrayValues = (bytes) => Array.from(bytes); let counter = 0; export const mkTempDbUrl = () => { diff --git a/test/TempDb.purs b/test/TempDb.purs index bbfcb95..80b2115 100644 --- a/test/TempDb.purs +++ b/test/TempDb.purs @@ -1,5 +1,10 @@ module Test.Sqlite.TempDb where +import Data.ArrayBuffer.Types (Uint8Array) import Effect (Effect) foreign import mkTempDbUrl :: Effect String + +foreign import testBytes :: Uint8Array + +foreign import uint8ArrayValues :: Uint8Array -> Array Int