Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions spago.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -140,6 +143,12 @@
"prelude"
]
},
"arraybuffer-types": {
"type": "registry",
"version": "3.0.2",
"integrity": "sha256-p05cJnSkyeoB7VHzMyc2Eb1RwUaB7Nl0sQSqEGNEUD8=",
"dependencies": []
},
"arrays": {
"type": "registry",
"version": "7.3.0",
Expand Down
1 change: 1 addition & 0 deletions spago.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions src/Yoga/SQLite/SQLite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
11 changes: 11 additions & 0 deletions src/Yoga/SQLite/SQLite.purs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down
24 changes: 24 additions & 0 deletions src/Yoga/SQLite/Schema.purs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down Expand Up @@ -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) <> ")"

Expand Down
21 changes: 20 additions & 1 deletion test/Main.purs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions test/TempDb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
5 changes: 5 additions & 0 deletions test/TempDb.purs
Original file line number Diff line number Diff line change
@@ -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
Loading