git.iko.soy/rowtype-yoga/purescript-yoga-scylladb
on main help sign in
Initial: yoga-scylladb
commit808c0eff74725a373b06a556e4a9970f6f0a2fd1 authorMark <mark.eibes@gmail.com> dateWed 4 Feb 2026, 21:57 (8 months ago)
8 files changed+1171
.github/workflows/ci.yml+9 .gitignore+3 package.json+22 spago.yaml+32 src/Yoga/ScyllaDB/ScyllaDB.js+250 src/Yoga/ScyllaDB/ScyllaDB.purs+364 src/Yoga/ScyllaDB/TypedQuery.purs+82 test/Main.purs+409
.github/workflows/ci.ymlnew file+9
@@ -0,0 +1,9 @@
1+name: CI
2+on: [push, pull_request]
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ - uses: rowtype-yoga/purescript-yoga-actions/.github/actions/setup@main
9+ - run: spago build
.gitignorenew file+3
@@ -0,0 +1,3 @@
1+node_modules/
2+.spago/
3+output*/
package.jsonnew file+22
@@ -0,0 +1,22 @@
1+{
2+ "name": "purescript-yoga-scylladb",
3+ "version": "0.1.0",
4+ "description": "PureScript FFI bindings for ScyllaDB/Cassandra",
5+ "license": "MIT",
6+ "repository": {
7+ "type": "git",
8+ "url": "https://github.com/rowtype-yoga/purescript-yoga-backend-stack.git",
9+ "directory": "packages/yoga-scylladb"
10+ },
11+ "keywords": [
12+ "purescript",
13+ "ffi",
14+ "bindings",
15+ "yoga"
16+ ],
17+ "scripts": {
18+ "test": "spago test",
19+ "test:bash": "./test-with-docker.sh",
20+ "test:ts": "bun run test-with-docker.ts"
21+ }
22+}
spago.yamlnew file+32
@@ -0,0 +1,32 @@
1+workspace:
2+ package_set:
3+ registry: 51.1.0
4+
5+package:
6+ name: yoga-scylladb
7+ publish:
8+ version: 0.1.0
9+ license: MIT
10+ location:
11+ githubOwner: rowtype-yoga
12+ githubRepo: purescript-yoga-scylladb
13+ dependencies:
14+ - js-promise: ">=1.0.0 <2.0.0"
15+ - js-promise-aff: ">=1.0.0 <2.0.0"
16+ - aff: ">=7.0.0 <8.0.0"
17+ - effect: ">=4.0.0 <5.0.0"
18+ - prelude: ">=6.0.0 <7.0.0"
19+ - yoga-sql-types: "*"
20+ - arrays: ">=7.0.0 <8.0.0"
21+ - foreign-object: ">=4.0.0 <5.0.0"
22+ - nullable: ">=6.0.0 <7.0.0"
23+ test:
24+ main: Test.Scylladb.Main
25+ dependencies:
26+ - spec: ">=7.0.0 <8.0.0"
27+ - exceptions: ">=6.0.0 <7.0.0"
28+ - console: ">=6.0.0 <7.0.0"
29+ - foreign: ">=7.0.0 <8.0.0"
30+ - maybe: ">=6.0.0 <7.0.0"
31+ - newtype: ">=5.0.0 <6.0.0"
32+ - yoga-test-docker
src/Yoga/ScyllaDB/ScyllaDB.jsnew file+250
@@ -0,0 +1,250 @@
1+import cassandra from 'cassandra-driver';
2+
3+// Map PureScript consistency levels to Cassandra driver
4+const mapConsistency = (level) => {
5+ const levelStr = String(level);
6+ switch (levelStr) {
7+ case 'ANY': return cassandra.types.consistencies.any;
8+ case 'ONE': return cassandra.types.consistencies.one;
9+ case 'TWO': return cassandra.types.consistencies.two;
10+ case 'THREE': return cassandra.types.consistencies.three;
11+ case 'QUORUM': return cassandra.types.consistencies.quorum;
12+ case 'ALL': return cassandra.types.consistencies.all;
13+ case 'LOCAL_QUORUM': return cassandra.types.consistencies.localQuorum;
14+ case 'EACH_QUORUM': return cassandra.types.consistencies.eachQuorum;
15+ case 'LOCAL_ONE': return cassandra.types.consistencies.localOne;
16+ default: return cassandra.types.consistencies.localQuorum;
17+ }
18+};
19+
20+// Create ScyllaDB client
21+export const createClientImpl = (config) => {
22+ const clientConfig = {
23+ contactPoints: config.contactPoints || [],
24+ localDataCenter: config.localDataCenter,
25+ keyspace: config.keyspace,
26+ protocolOptions: {},
27+ socketOptions: {},
28+ queryOptions: {},
29+ pooling: {}
30+ };
31+
32+ // Credentials
33+ if (config.credentials) {
34+ clientConfig.credentials = {
35+ username: config.credentials.username,
36+ password: config.credentials.password
37+ };
38+ }
39+
40+ // Pooling options
41+ if (config.pooling) {
42+ if (config.pooling.coreConnectionsPerHost) {
43+ clientConfig.pooling.coreConnectionsPerHost = config.pooling.coreConnectionsPerHost;
44+ }
45+ if (config.pooling.maxConnectionsPerHost) {
46+ clientConfig.pooling.maxConnectionsPerHost = config.pooling.maxConnectionsPerHost;
47+ }
48+ }
49+
50+ // Socket options
51+ if (config.socketOptions) {
52+ if (config.socketOptions.connectTimeout) {
53+ clientConfig.socketOptions.connectTimeout = config.socketOptions.connectTimeout;
54+ }
55+ if (config.socketOptions.readTimeout) {
56+ clientConfig.socketOptions.readTimeout = config.socketOptions.readTimeout;
57+ }
58+ }
59+
60+ // Query options
61+ if (config.queryOptions) {
62+ if (config.queryOptions.consistency) {
63+ clientConfig.queryOptions.consistency = mapConsistency(config.queryOptions.consistency);
64+ }
65+ if (config.queryOptions.prepare !== undefined) {
66+ clientConfig.queryOptions.prepare = config.queryOptions.prepare;
67+ }
68+ if (config.queryOptions.fetchSize) {
69+ clientConfig.queryOptions.fetchSize = config.queryOptions.fetchSize;
70+ }
71+ }
72+
73+ // Protocol options
74+ if (config.protocolOptions) {
75+ if (config.protocolOptions.port) {
76+ clientConfig.protocolOptions.port = config.protocolOptions.port;
77+ }
78+ if (config.protocolOptions.maxSchemaAgreementWaitSeconds) {
79+ clientConfig.protocolOptions.maxSchemaAgreementWaitSeconds =
80+ config.protocolOptions.maxSchemaAgreementWaitSeconds;
81+ }
82+ }
83+
84+ // Policies (pass through as-is if provided)
85+ if (config.policies) {
86+ clientConfig.policies = config.policies;
87+ }
88+
89+ // Pass through any other options
90+ const client = new cassandra.Client({
91+ ...clientConfig,
92+ ...config
93+ });
94+
95+ return client;
96+};
97+
98+// Connect to cluster
99+export const connectImpl = (client) => client.connect();
100+
101+// Execute query
102+export const executeImpl = async (client, query, params) => {
103+ const result = await client.execute(query, params, { prepare: true });
104+ return {
105+ rows: result.rows || [],
106+ pageState: result.pageState || null,
107+ rowLength: result.rowLength || (result.rows ? result.rows.length : 0)
108+ };
109+};
110+
111+// Execute query with options
112+export const executeWithOptionsImpl = async (client, query, params, options) => {
113+ const execOptions = { ...options };
114+
115+ // Map consistency if provided
116+ if (options.consistency) {
117+ execOptions.consistency = mapConsistency(options.consistency);
118+ }
119+
120+ const result = await client.execute(query, params, execOptions);
121+ return {
122+ rows: result.rows || [],
123+ pageState: result.pageState || null,
124+ rowLength: result.rowLength || (result.rows ? result.rows.length : 0)
125+ };
126+};
127+
128+// Execute batch queries
129+export const batchImpl = async (client, queries) => {
130+ const result = await client.batch(queries, { prepare: true });
131+ return {
132+ rows: result.rows || [],
133+ pageState: result.pageState || null,
134+ rowLength: result.rowLength || 0
135+ };
136+};
137+
138+// Execute batch with options
139+export const batchWithOptionsImpl = async (client, queries, options) => {
140+ const execOptions = { ...options };
141+
142+ // Map consistency if provided
143+ if (options.consistency) {
144+ execOptions.consistency = mapConsistency(options.consistency);
145+ }
146+
147+ // Map logged batch type
148+ if (options.logged !== undefined) {
149+ execOptions.logged = options.logged;
150+ }
151+
152+ // Map counter batch type
153+ if (options.counter !== undefined) {
154+ execOptions.counter = options.counter;
155+ }
156+
157+ const result = await client.batch(queries, execOptions);
158+ return {
159+ rows: result.rows || [],
160+ pageState: result.pageState || null,
161+ rowLength: result.rowLength || 0
162+ };
163+};
164+
165+// Stream query results
166+export const streamImpl = async (client, query, params) => {
167+ return new Promise((resolve, reject) => {
168+ const rows = [];
169+
170+ client.stream(query, params, { prepare: true })
171+ .on('readable', function() {
172+ let row;
173+ while (row = this.read()) {
174+ rows.push(row);
175+ }
176+ })
177+ .on('end', () => {
178+ resolve(rows);
179+ })
180+ .on('error', (err) => {
181+ reject(err);
182+ });
183+ });
184+};
185+
186+// Prepared statements
187+export const prepareImpl = async (client, query) => {
188+ return client.connect().then(() => {
189+ // The driver automatically prepares queries when prepare: true is used
190+ // We return a prepared query object
191+ return { query, client };
192+ });
193+};
194+
195+export const executePreparedImpl = async (client, preparedStmt, params) => {
196+ const result = await client.execute(preparedStmt.query, params, { prepare: true });
197+ return {
198+ rows: result.rows || [],
199+ pageState: result.pageState || null,
200+ rowLength: result.rowLength || (result.rows ? result.rows.length : 0)
201+ };
202+};
203+
204+// Metadata operations
205+export const getKeyspaceImpl = (client, keyspace) => {
206+ const metadata = client.metadata;
207+ const ks = metadata.keyspaces[keyspace];
208+ return ks || null;
209+};
210+
211+export const getTableImpl = (client, keyspace, table) => {
212+ const metadata = client.metadata;
213+ const ks = metadata.keyspaces[keyspace];
214+ if (!ks) return null;
215+
216+ const tbl = ks.tables[table];
217+ return tbl || null;
218+};
219+
220+// Get hosts
221+export const getHostsImpl = (client) => {
222+ const hosts = client.getState().getConnectedHosts();
223+ return Array.from(hosts);
224+};
225+
226+// Shutdown client
227+export const shutdownImpl = (client) => client.shutdown();
228+
229+// Health check
230+export const pingImpl = async (client) => {
231+ try {
232+ await client.execute('SELECT now() FROM system.local', [], { prepare: false });
233+ return true;
234+ } catch (err) {
235+ return false;
236+ }
237+};
238+
239+// UUID helpers
240+export const uuidImpl = () => cassandra.types.Uuid.random();
241+
242+export const timeUuidImpl = () => cassandra.types.TimeUuid.now();
243+
244+export const uuidFromStringImpl = (str) => {
245+ try {
246+ return cassandra.types.Uuid.fromString(str);
247+ } catch (err) {
248+ return null;
249+ }
250+};
src/Yoga/ScyllaDB/ScyllaDB.pursnew file+364
@@ -0,0 +1,364 @@
1+module Yoga.ScyllaDB.ScyllaDB where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Maybe (Maybe)
7+import Data.Newtype (class Newtype)
8+import Data.Nullable (Nullable)
9+import Data.Nullable as Nullable
10+import Data.Time.Duration (Milliseconds)
11+import Effect (Effect)
12+import Effect.Aff (Aff)
13+import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, EffectFn4, runEffectFn1, runEffectFn2, runEffectFn3, runEffectFn4)
14+import Foreign (Foreign)
15+import Foreign.Object (Object)
16+import Prim.Row (class Union)
17+import Promise (Promise)
18+import Promise.Aff (toAffE) as Promise
19+import Unsafe.Coerce (unsafeCoerce)
20+
21+-- Opaque ScyllaDB types
22+foreign import data Client :: Type
23+foreign import data ResultSet :: Type
24+
25+-- Newtypes for type safety
26+
27+-- Connection configuration
28+newtype ContactPoint = ContactPoint String
29+
30+derive instance Newtype ContactPoint _
31+derive newtype instance Eq ContactPoint
32+derive newtype instance Show ContactPoint
33+
34+newtype Keyspace = Keyspace String
35+
36+derive instance Newtype Keyspace _
37+derive newtype instance Eq Keyspace
38+derive newtype instance Show Keyspace
39+
40+newtype Datacenter = Datacenter String
41+
42+derive instance Newtype Datacenter _
43+derive newtype instance Eq Datacenter
44+derive newtype instance Show Datacenter
45+
46+newtype Username = Username String
47+
48+derive instance Newtype Username _
49+derive newtype instance Eq Username
50+derive newtype instance Show Username
51+
52+newtype Password = Password String
53+
54+derive instance Newtype Password _
55+derive newtype instance Eq Password
56+derive newtype instance Show Password
57+
58+newtype PoolSize = PoolSize Int
59+
60+derive instance Newtype PoolSize _
61+derive newtype instance Eq PoolSize
62+derive newtype instance Ord PoolSize
63+derive newtype instance Show PoolSize
64+
65+newtype ReplicationFactor = ReplicationFactor Int
66+
67+derive instance Newtype ReplicationFactor _
68+derive newtype instance Eq ReplicationFactor
69+derive newtype instance Ord ReplicationFactor
70+derive newtype instance Show ReplicationFactor
71+
72+-- Query types
73+newtype CQL = CQL String
74+
75+derive instance Newtype CQL _
76+derive newtype instance Eq CQL
77+derive newtype instance Show CQL
78+
79+-- Consistency levels
80+data ConsistencyLevel
81+ = Any
82+ | One
83+ | Two
84+ | Three
85+ | Quorum
86+ | All
87+ | LocalQuorum
88+ | EachQuorum
89+ | LocalOne
90+
91+derive instance Eq ConsistencyLevel
92+derive instance Ord ConsistencyLevel
93+
94+instance Show ConsistencyLevel where
95+ show Any = "ANY"
96+ show One = "ONE"
97+ show Two = "TWO"
98+ show Three = "THREE"
99+ show Quorum = "QUORUM"
100+ show All = "ALL"
101+ show LocalQuorum = "LOCAL_QUORUM"
102+ show EachQuorum = "EACH_QUORUM"
103+ show LocalOne = "LOCAL_ONE"
104+
105+-- Result types
106+type Row = Foreign
107+
108+type QueryResultImpl =
109+ { rows :: Array Row
110+ , pageState :: Nullable String
111+ , rowLength :: Int
112+ }
113+
114+type QueryResult =
115+ { rows :: Array Row
116+ , pageState :: Maybe String
117+ , rowLength :: Int
118+ }
119+
120+toQueryResult :: QueryResultImpl -> QueryResult
121+toQueryResult r =
122+ { rows: r.rows
123+ , pageState: Nullable.toMaybe r.pageState
124+ , rowLength: r.rowLength
125+ }
126+
127+-- Client configuration
128+type ClientOptionsImpl =
129+ ( contactPoints :: Array ContactPoint
130+ , localDataCenter :: Datacenter
131+ , keyspace :: Keyspace
132+ , credentials ::
133+ { username :: Username
134+ , password :: Password
135+ }
136+ , pooling ::
137+ { coreConnectionsPerHost :: PoolSize
138+ , maxConnectionsPerHost :: PoolSize
139+ }
140+ , socketOptions ::
141+ { connectTimeout :: Milliseconds
142+ , readTimeout :: Milliseconds
143+ }
144+ , queryOptions ::
145+ { consistency :: ConsistencyLevel
146+ , prepare :: Boolean
147+ , fetchSize :: Int
148+ }
149+ , protocolOptions ::
150+ { port :: Int
151+ , maxSchemaAgreementWaitSeconds :: Int
152+ }
153+ , policies ::
154+ { loadBalancing :: Foreign
155+ , retry :: Foreign
156+ , reconnection :: Foreign
157+ }
158+ )
159+
160+-- Create ScyllaDB client
161+foreign import createClientImpl :: forall opts. EffectFn1 { | opts } Client
162+
163+createClient :: forall opts opts_. Union opts opts_ ClientOptionsImpl => { | opts } -> Effect Client
164+createClient opts = runEffectFn1 createClientImpl opts
165+
166+-- Connect to cluster
167+foreign import connectImpl :: EffectFn1 Client (Promise Unit)
168+
169+connect :: Client -> Aff Unit
170+connect = runEffectFn1 connectImpl >>> Promise.toAffE
171+
172+-- Execute query
173+foreign import executeImpl :: EffectFn3 Client CQL (Array Foreign) (Promise QueryResultImpl)
174+
175+execute :: CQL -> Array Foreign -> Client -> Aff QueryResult
176+execute cql params client =
177+ runEffectFn3 executeImpl client cql params
178+ # Promise.toAffE
179+ <#> toQueryResult
180+
181+-- Execute query with options
182+type ExecuteOptionsImpl =
183+ ( consistency :: ConsistencyLevel
184+ , prepare :: Boolean
185+ , fetchSize :: Int
186+ , pageState :: String
187+ , traceQuery :: Boolean
188+ , timestamp :: Number
189+ )
190+
191+foreign import executeWithOptionsImpl :: forall opts. EffectFn4 Client CQL (Array Foreign) { | opts } (Promise QueryResultImpl)
192+
193+executeWithOptions :: forall opts opts_. Union opts opts_ ExecuteOptionsImpl => CQL -> Array Foreign -> { | opts } -> Client -> Aff QueryResult
194+executeWithOptions cql params opts client =
195+ runEffectFn4 executeWithOptionsImpl client cql params opts
196+ # Promise.toAffE
197+ <#> toQueryResult
198+
199+-- Execute batch queries
200+type BatchQuery =
201+ { query :: CQL
202+ , params :: Array Foreign
203+ }
204+
205+foreign import batchImpl :: EffectFn2 Client (Array BatchQuery) (Promise QueryResultImpl)
206+
207+batch :: Array BatchQuery -> Client -> Aff QueryResult
208+batch queries client =
209+ runEffectFn2 batchImpl client queries
210+ # Promise.toAffE
211+ <#> toQueryResult
212+
213+-- Execute batch with options
214+type BatchOptionsImpl =
215+ ( consistency :: ConsistencyLevel
216+ , logged :: Boolean
217+ , counter :: Boolean
218+ , timestamp :: Number
219+ )
220+
221+foreign import batchWithOptionsImpl :: forall opts. EffectFn3 Client (Array BatchQuery) { | opts } (Promise QueryResultImpl)
222+
223+batchWithOptions :: forall opts opts_. Union opts opts_ BatchOptionsImpl => Array BatchQuery -> { | opts } -> Client -> Aff QueryResult
224+batchWithOptions queries opts client =
225+ runEffectFn3 batchWithOptionsImpl client queries opts
226+ # Promise.toAffE
227+ <#> toQueryResult
228+
229+-- Stream query results (returns array for simplicity - could be enhanced with streaming)
230+foreign import streamImpl :: EffectFn3 Client CQL (Array Foreign) (Promise (Array Row))
231+
232+stream :: CQL -> Array Foreign -> Client -> Aff (Array Row)
233+stream cql params client =
234+ runEffectFn3 streamImpl client cql params
235+ # Promise.toAffE
236+
237+-- Prepared statements
238+foreign import data PreparedStatement :: Type
239+
240+foreign import prepareImpl :: EffectFn2 Client CQL (Promise PreparedStatement)
241+
242+prepare :: CQL -> Client -> Aff PreparedStatement
243+prepare cql client =
244+ runEffectFn2 prepareImpl client cql
245+ # Promise.toAffE
246+
247+foreign import executePreparedImpl :: EffectFn3 Client PreparedStatement (Array Foreign) (Promise QueryResultImpl)
248+
249+executePrepared :: PreparedStatement -> Array Foreign -> Client -> Aff QueryResult
250+executePrepared stmt params client =
251+ runEffectFn3 executePreparedImpl client stmt params
252+ # Promise.toAffE
253+ <#> toQueryResult
254+
255+-- Metadata operations
256+foreign import getKeyspaceImpl :: EffectFn2 Client Keyspace (Nullable Foreign)
257+
258+getKeyspace :: Keyspace -> Client -> Effect (Maybe Foreign)
259+getKeyspace keyspace client =
260+ runEffectFn2 getKeyspaceImpl client keyspace
261+ <#> Nullable.toMaybe
262+
263+foreign import getTableImpl :: EffectFn3 Client Keyspace String (Nullable Foreign)
264+
265+getTable :: Keyspace -> String -> Client -> Effect (Maybe Foreign)
266+getTable keyspace table client =
267+ runEffectFn3 getTableImpl client keyspace table
268+ <#> Nullable.toMaybe
269+
270+-- Get hosts
271+foreign import getHostsImpl :: EffectFn1 Client (Array Foreign)
272+
273+getHosts :: Client -> Effect (Array Foreign)
274+getHosts = runEffectFn1 getHostsImpl
275+
276+-- Shutdown client
277+foreign import shutdownImpl :: EffectFn1 Client (Promise Unit)
278+
279+shutdown :: Client -> Aff Unit
280+shutdown = runEffectFn1 shutdownImpl >>> Promise.toAffE
281+
282+-- Health check
283+foreign import pingImpl :: EffectFn1 Client (Promise Boolean)
284+
285+ping :: Client -> Aff Boolean
286+ping = runEffectFn1 pingImpl >>> Promise.toAffE
287+
288+-- Type-safe CQL parameters (like Postgres ToPGValue)
289+class ToCQLValue a where
290+ toCQLValue :: a -> Foreign
291+
292+instance ToCQLValue String where
293+ toCQLValue = unsafeCoerce
294+
295+instance ToCQLValue Int where
296+ toCQLValue = unsafeCoerce
297+
298+instance ToCQLValue Number where
299+ toCQLValue = unsafeCoerce
300+
301+instance ToCQLValue Boolean where
302+ toCQLValue = unsafeCoerce
303+
304+instance ToCQLValue Foreign where
305+ toCQLValue = identity
306+
307+instance ToCQLValue UUID where
308+ toCQLValue = unsafeCoerce
309+
310+instance ToCQLValue a => ToCQLValue (Array a) where
311+ toCQLValue arr = unsafeCoerce (map toCQLValue arr)
312+
313+-- Parameter helpers
314+param :: forall a. ToCQLValue a => a -> Array Foreign
315+param x = [ toCQLValue x ]
316+
317+params :: forall a. ToCQLValue a => Array a -> Array Foreign
318+params xs = map toCQLValue xs
319+
320+-- UUID helpers
321+foreign import data UUID :: Type
322+
323+foreign import uuidImpl :: Effect UUID
324+
325+uuid :: Effect UUID
326+uuid = uuidImpl
327+
328+foreign import timeUuidImpl :: Effect UUID
329+
330+timeUuid :: Effect UUID
331+timeUuid = timeUuidImpl
332+
333+foreign import uuidFromStringImpl :: EffectFn1 String (Nullable UUID)
334+
335+uuidFromString :: String -> Effect (Maybe UUID)
336+uuidFromString str =
337+ runEffectFn1 uuidFromStringImpl str
338+ <#> Nullable.toMaybe
339+
340+-- Common query patterns
341+
342+-- Create keyspace
343+createKeyspace :: Keyspace -> ReplicationFactor -> Client -> Aff QueryResult
344+createKeyspace (Keyspace ks) (ReplicationFactor rf) client =
345+ execute
346+ (CQL $ "CREATE KEYSPACE IF NOT EXISTS " <> ks <> " WITH replication = {'class': 'SimpleStrategy', 'replication_factor': " <> show rf <> "}")
347+ []
348+ client
349+
350+-- Drop keyspace
351+dropKeyspace :: Keyspace -> Client -> Aff QueryResult
352+dropKeyspace (Keyspace ks) client =
353+ execute
354+ (CQL $ "DROP KEYSPACE IF EXISTS " <> ks)
355+ []
356+ client
357+
358+-- Use keyspace
359+useKeyspace :: Keyspace -> Client -> Aff QueryResult
360+useKeyspace (Keyspace ks) client =
361+ execute
362+ (CQL $ "USE " <> ks)
363+ []
364+ client
src/Yoga/ScyllaDB/TypedQuery.pursnew file+82
@@ -0,0 +1,82 @@
1+module Yoga.ScyllaDB.TypedQuery where
2+
3+import Prelude
4+
5+import Data.Either (Either(..))
6+import Data.Map (Map)
7+import Data.Maybe (Maybe(..))
8+import Data.Traversable (traverse)
9+import Effect.Aff (Aff)
10+import Foreign (Foreign)
11+import Heterogeneous.Folding (class HFoldlWithIndex)
12+import Yoga.ScyllaDB.ScyllaDB as Scylla
13+import Yoga.SQL.Types (SQLParameter, SQLQuery, TurnIntoSQLParam, argsFor, sqlQueryToString)
14+import Unsafe.Coerce (unsafeCoerce)
15+import Yoga.JSON (class ReadForeign)
16+import Yoga.JSON as JSON
17+
18+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19+-- Convert SQL Types to CQL Types
20+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21+
22+-- | Convert a SQLParameter to Foreign for ScyllaDB
23+sqlParamToForeign :: SQLParameter -> Foreign
24+sqlParamToForeign = unsafeCoerce
25+
26+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27+-- Type-Safe Query Execution
28+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29+
30+-- | Execute a typed CQL query and parse results with yoga-json
31+executeSql
32+ :: forall @params @result
33+ . HFoldlWithIndex TurnIntoSQLParam (Map String SQLParameter) { | params } (Map String SQLParameter)
34+ => ReadForeign result
35+ => SQLQuery params
36+ -> { | params }
37+ -> Scylla.Client
38+ -> Aff (Either String (Array result))
39+executeSql sqlQuery params client = do
40+ let
41+ cql = Scylla.CQL (sqlQueryToString sqlQuery)
42+ sqlParams = argsFor sqlQuery params
43+ foreignParams = map sqlParamToForeign sqlParams
44+ result <- Scylla.execute cql foreignParams client
45+ pure $ traverse parseRow result.rows
46+ where
47+ parseRow :: Foreign -> Either String result
48+ parseRow row = case (JSON.read row :: Either _ result) of
49+ Left errors -> Left (show errors)
50+ Right parsed -> Right parsed
51+
52+-- | Execute a typed CQL query and return a single row
53+executeSqlOne
54+ :: forall @params @result
55+ . HFoldlWithIndex TurnIntoSQLParam (Map String SQLParameter) { | params } (Map String SQLParameter)
56+ => ReadForeign result
57+ => SQLQuery params
58+ -> { | params }
59+ -> Scylla.Client
60+ -> Aff (Either String (Maybe result))
61+executeSqlOne sqlQuery params client = do
62+ result <- executeSql @params @result sqlQuery params client
63+ pure $ result <#> case _ of
64+ [ x ] -> Just x
65+ [] -> Nothing
66+ _ -> Nothing -- More than one result, return Nothing
67+
68+-- | Execute a mutation (INSERT/UPDATE/DELETE)
69+executeMutation
70+ :: forall @params
71+ . HFoldlWithIndex TurnIntoSQLParam (Map String SQLParameter) { | params } (Map String SQLParameter)
72+ => SQLQuery params
73+ -> { | params }
74+ -> Scylla.Client
75+ -> Aff Unit
76+executeMutation sqlQuery params client = do
77+ let
78+ cql = Scylla.CQL (sqlQueryToString sqlQuery)
79+ sqlParams = argsFor sqlQuery params
80+ foreignParams = map sqlParamToForeign sqlParams
81+ _ <- Scylla.execute cql foreignParams client
82+ pure unit
test/Main.pursnew file+409
@@ -0,0 +1,409 @@
1+module Test.Scylladb.Main where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Either (Either(..))
7+import Data.Maybe (Maybe(..))
8+import Effect (Effect)
9+import Effect.Aff (Aff, bracket, delay, launchAff_, throwError, try)
10+import Effect.Class (liftEffect)
11+import Effect.Console (log)
12+import Effect.Exception (error)
13+import Data.Time.Duration (Milliseconds(..))
14+import Test.Spec (Spec, around, describe, it)
15+import Test.Spec.Assertions (shouldEqual, shouldSatisfy)
16+import Test.Spec.Reporter.Console (consoleReporter)
17+import Test.Spec.Runner (runSpec)
18+import Yoga.Test.Docker as Docker
19+import Yoga.ScyllaDB.ScyllaDB as Scylla
20+
21+-- Test configuration
22+testContactPoint :: Scylla.ContactPoint
23+testContactPoint = Scylla.ContactPoint "127.0.0.1"
24+
25+testDatacenter :: Scylla.Datacenter
26+testDatacenter = Scylla.Datacenter "datacenter1"
27+
28+testKeyspace :: Scylla.Keyspace
29+testKeyspace = Scylla.Keyspace "test_ks"
30+
31+testPort :: Int
32+testPort = 9043 -- Test port from docker-compose.test.yml
33+
34+-- Helper to create and manage ScyllaDB connection
35+withScylla :: (Scylla.Client -> Aff Unit) -> Aff Unit
36+withScylla test = do
37+ client <- liftEffect $ Scylla.createClient
38+ { contactPoints: [ testContactPoint ]
39+ , localDataCenter: testDatacenter
40+ , protocolOptions: { port: testPort, maxSchemaAgreementWaitSeconds: 10 }
41+ }
42+ _ <- Scylla.connect client
43+
44+ -- Wait for cluster to be ready
45+ delay (Milliseconds 1000.0)
46+
47+ -- Clean up test keyspace before each test
48+ _ <- try $ Scylla.dropKeyspace testKeyspace client
49+
50+ test client
51+
52+ _ <- Scylla.shutdown client
53+ pure unit
54+
55+-- Helper to check if value is Left
56+isLeft :: forall a b. Either a b -> Boolean
57+isLeft (Left _) = true
58+isLeft _ = false
59+
60+-- Helper to set up test keyspace
61+setupKeyspace :: Scylla.Client -> Aff Unit
62+setupKeyspace client = do
63+ _ <- Scylla.createKeyspace testKeyspace (Scylla.ReplicationFactor 1) client
64+ _ <- Scylla.useKeyspace testKeyspace client
65+ pure unit
66+
67+-- Helper to set up users table
68+setupUsersTable :: Scylla.Client -> Aff Unit
69+setupUsersTable client = do
70+ _ <- Scylla.execute
71+ ( Scylla.CQL
72+ """
73+ CREATE TABLE test_ks.users (
74+ id uuid PRIMARY KEY,
75+ name text,
76+ email text,
77+ age int,
78+ active boolean
79+ )
80+ """
81+ )
82+ []
83+ client
84+ pure unit
85+
86+spec :: Spec Unit
87+spec = do
88+ describe "Yoga.ScyllaDB Integration Tests" do
89+
90+ -- Connection Management Tests
91+ around withScylla do
92+ describe "Connection Management" do
93+ it "connects to ScyllaDB successfully" \client -> do
94+ healthy <- Scylla.ping client
95+ healthy `shouldEqual` true
96+
97+ it "retrieves cluster hosts" \client -> do
98+ hosts <- liftEffect $ Scylla.getHosts client
99+ (Array.length hosts) `shouldSatisfy` (\l -> l > 0)
100+
101+ describe "Connection Errors" do
102+ it "handles connection failures" do
103+ result <- try do
104+ client <- liftEffect $ Scylla.createClient
105+ { contactPoints: [ Scylla.ContactPoint "invalid-host-9999" ]
106+ , localDataCenter: testDatacenter
107+ , protocolOptions: { port: 9999, maxSchemaAgreementWaitSeconds: 10 }
108+ }
109+ Scylla.connect client
110+ result `shouldSatisfy` isLeft
111+
112+ -- Keyspace Operations Tests
113+ around withScylla do
114+ describe "Keyspace Operations" do
115+ it "creates and drops keyspaces" \client -> do
116+ _ <- Scylla.createKeyspace testKeyspace (Scylla.ReplicationFactor 1) client
117+ _ <- Scylla.dropKeyspace testKeyspace client
118+ pure unit
119+
120+ it "uses keyspace" \client -> do
121+ setupKeyspace client
122+ result <- Scylla.useKeyspace testKeyspace client
123+ result.rowLength `shouldEqual` 0
124+
125+ it "retrieves keyspace metadata" \client -> do
126+ setupKeyspace client
127+ meta <- liftEffect $ Scylla.getKeyspace testKeyspace client
128+ case meta of
129+ Just _ -> pure unit
130+ Nothing -> throwError (error "Expected to find keyspace metadata")
131+
132+ -- Table Operations Tests
133+ around withScylla do
134+ describe "Table Operations" do
135+ it "creates tables with various column types" \client -> do
136+ setupKeyspace client
137+ result <- Scylla.execute
138+ ( Scylla.CQL
139+ """
140+ CREATE TABLE test_ks.users (
141+ id uuid PRIMARY KEY,
142+ name text,
143+ age int,
144+ email text,
145+ active boolean,
146+ created_at timestamp
147+ )
148+ """
149+ )
150+ []
151+ client
152+ result.rowLength `shouldEqual` 0
153+
154+ it "inserts and queries data" \client -> do
155+ setupKeyspace client
156+ setupUsersTable client
157+
158+ userId <- liftEffect Scylla.uuid
159+ _ <- Scylla.execute (Scylla.CQL "INSERT INTO test_ks.users (id, name, age) VALUES (?, ?, ?)")
160+ [ Scylla.toCQLValue userId, Scylla.toCQLValue "Alice", Scylla.toCQLValue 30 ]
161+ client
162+
163+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users WHERE id = ?")
164+ [ Scylla.toCQLValue userId ]
165+ client
166+ result.rowLength `shouldEqual` 1
167+
168+ it "updates data" \client -> do
169+ setupKeyspace client
170+ setupUsersTable client
171+
172+ userId <- liftEffect Scylla.uuid
173+ _ <- Scylla.execute (Scylla.CQL "INSERT INTO test_ks.users (id, name, email) VALUES (?, ?, ?)")
174+ [ Scylla.toCQLValue userId, Scylla.toCQLValue "Bob", Scylla.toCQLValue "bob@example.com" ]
175+ client
176+
177+ _ <- Scylla.execute (Scylla.CQL "UPDATE test_ks.users SET email = ? WHERE id = ?")
178+ [ Scylla.toCQLValue "bob.new@example.com", Scylla.toCQLValue userId ]
179+ client
180+
181+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users WHERE id = ?")
182+ [ Scylla.toCQLValue userId ]
183+ client
184+ result.rowLength `shouldEqual` 1
185+
186+ it "deletes data" \client -> do
187+ setupKeyspace client
188+ setupUsersTable client
189+
190+ userId <- liftEffect Scylla.uuid
191+ _ <- Scylla.execute (Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)")
192+ [ Scylla.toCQLValue userId, Scylla.toCQLValue "Charlie" ]
193+ client
194+
195+ _ <- Scylla.execute (Scylla.CQL "DELETE FROM test_ks.users WHERE id = ?")
196+ [ Scylla.toCQLValue userId ]
197+ client
198+
199+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users WHERE id = ?")
200+ [ Scylla.toCQLValue userId ]
201+ client
202+ result.rowLength `shouldEqual` 0
203+
204+ -- Consistency Level Tests
205+ around withScylla do
206+ describe "Consistency Levels" do
207+ it "executes with ONE consistency level" \client -> do
208+ setupKeyspace client
209+ result <- Scylla.executeWithOptions
210+ (Scylla.CQL "SELECT * FROM system.local")
211+ []
212+ { consistency: Scylla.One }
213+ client
214+ result.rowLength `shouldSatisfy` (\l -> l > 0)
215+
216+ it "executes with QUORUM consistency level" \client -> do
217+ setupKeyspace client
218+ result <- Scylla.executeWithOptions
219+ (Scylla.CQL "SELECT * FROM system.local")
220+ []
221+ { consistency: Scylla.Quorum }
222+ client
223+ result.rowLength `shouldSatisfy` (\l -> l > 0)
224+
225+ it "executes with LOCAL_ONE consistency level" \client -> do
226+ setupKeyspace client
227+ result <- Scylla.executeWithOptions
228+ (Scylla.CQL "SELECT * FROM system.local")
229+ []
230+ { consistency: Scylla.LocalOne }
231+ client
232+ result.rowLength `shouldSatisfy` (\l -> l > 0)
233+
234+ -- Prepared Statement Tests
235+ around withScylla do
236+ describe "Prepared Statements" do
237+ it "prepares and executes statements" \client -> do
238+ setupKeyspace client
239+ setupUsersTable client
240+
241+ prepared <- Scylla.prepare
242+ (Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)")
243+ client
244+
245+ userId <- liftEffect Scylla.uuid
246+ result <- Scylla.executePrepared prepared
247+ [ Scylla.toCQLValue userId, Scylla.toCQLValue "Diana" ]
248+ client
249+ result.rowLength `shouldEqual` 0
250+
251+ it "reuses prepared statements for multiple inserts" \client -> do
252+ setupKeyspace client
253+ setupUsersTable client
254+
255+ prepared <- Scylla.prepare
256+ (Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)")
257+ client
258+
259+ userId1 <- liftEffect Scylla.uuid
260+ userId2 <- liftEffect Scylla.uuid
261+ userId3 <- liftEffect Scylla.uuid
262+
263+ _ <- Scylla.executePrepared prepared [ Scylla.toCQLValue userId1, Scylla.toCQLValue "Eve" ] client
264+ _ <- Scylla.executePrepared prepared [ Scylla.toCQLValue userId2, Scylla.toCQLValue "Frank" ] client
265+ _ <- Scylla.executePrepared prepared [ Scylla.toCQLValue userId3, Scylla.toCQLValue "Grace" ] client
266+
267+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users") [] client
268+ result.rowLength `shouldEqual` 3
269+
270+ -- Batch Operations Tests
271+ around withScylla do
272+ describe "Batch Operations" do
273+ it "executes batch inserts" \client -> do
274+ setupKeyspace client
275+ setupUsersTable client
276+
277+ userId1 <- liftEffect Scylla.uuid
278+ userId2 <- liftEffect Scylla.uuid
279+
280+ let
281+ batchQueries =
282+ [ { query: Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)"
283+ , params: [ Scylla.toCQLValue userId1, Scylla.toCQLValue "Henry" ]
284+ }
285+ , { query: Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)"
286+ , params: [ Scylla.toCQLValue userId2, Scylla.toCQLValue "Iris" ]
287+ }
288+ ]
289+
290+ _ <- Scylla.batch batchQueries client
291+
292+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users") [] client
293+ result.rowLength `shouldEqual` 2
294+
295+ it "executes batch with consistency options" \client -> do
296+ setupKeyspace client
297+ setupUsersTable client
298+
299+ userId <- liftEffect Scylla.uuid
300+
301+ let
302+ batchQueries =
303+ [ { query: Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)"
304+ , params: [ Scylla.toCQLValue userId, Scylla.toCQLValue "Jack" ]
305+ }
306+ ]
307+
308+ result <- Scylla.batchWithOptions batchQueries
309+ { consistency: Scylla.One, logged: true }
310+ client
311+ result.rowLength `shouldEqual` 0
312+
313+ -- UUID Tests
314+ around withScylla do
315+ describe "UUID Operations" do
316+ it "generates UUIDs" \_ -> do
317+ _ <- liftEffect Scylla.uuid
318+ _ <- liftEffect Scylla.uuid
319+ pure unit -- Just check it doesn't error
320+
321+ it "generates time-based UUIDs" \_ -> do
322+ _ <- liftEffect Scylla.timeUuid
323+ _ <- liftEffect Scylla.timeUuid
324+ pure unit -- Just check it doesn't error
325+
326+ it "parses UUIDs from strings" \_ -> do
327+ -- Valid UUID v4 format
328+ maybeUuid <- liftEffect $ Scylla.uuidFromString "550e8400-e29b-41d4-a716-446655440000"
329+ case maybeUuid of
330+ Just _ -> pure unit
331+ Nothing -> throwError (error "Expected to parse valid UUID")
332+
333+ it "returns Nothing for invalid UUID strings" \_ -> do
334+ maybeUuid <- liftEffect $ Scylla.uuidFromString "not-a-valid-uuid"
335+ case maybeUuid of
336+ Nothing -> pure unit
337+ Just _ -> throwError (error "Expected Nothing for invalid UUID")
338+
339+ -- Data Type Tests
340+ around withScylla do
341+ describe "Data Types" do
342+ it "handles various CQL data types" \client -> do
343+ setupKeyspace client
344+ setupUsersTable client
345+
346+ userId <- liftEffect Scylla.uuid
347+ _ <- Scylla.execute (Scylla.CQL "INSERT INTO test_ks.users (id, name, age, active) VALUES (?, ?, ?, ?)")
348+ [ Scylla.toCQLValue userId, Scylla.toCQLValue "Kate", Scylla.toCQLValue 25, Scylla.toCQLValue true ]
349+ client
350+
351+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users WHERE id = ?")
352+ [ Scylla.toCQLValue userId ]
353+ client
354+ result.rowLength `shouldEqual` 1
355+
356+ it "handles null values" \client -> do
357+ setupKeyspace client
358+ setupUsersTable client
359+
360+ userId <- liftEffect Scylla.uuid
361+ _ <- Scylla.execute (Scylla.CQL "INSERT INTO test_ks.users (id, name) VALUES (?, ?)")
362+ [ Scylla.toCQLValue userId, Scylla.toCQLValue "Leo" ]
363+ client
364+
365+ result <- Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.users WHERE id = ?")
366+ [ Scylla.toCQLValue userId ]
367+ client
368+ result.rowLength `shouldEqual` 1
369+
370+ -- Error Handling Tests
371+ around withScylla do
372+ describe "Error Handling" do
373+ it "handles invalid CQL syntax" \client -> do
374+ result <- try $ Scylla.execute (Scylla.CQL "INVALID CQL SYNTAX") [] client
375+ case result of
376+ Left _ -> pure unit
377+ Right _ -> throwError (error "Expected error for invalid CQL syntax")
378+
379+ it "handles missing table errors" \client -> do
380+ setupKeyspace client
381+ result <- try $ Scylla.execute (Scylla.CQL "SELECT * FROM test_ks.non_existent_table") [] client
382+ case result of
383+ Left _ -> pure unit
384+ Right _ -> throwError (error "Expected error for missing table")
385+
386+ it "handles missing keyspace errors" \client -> do
387+ result <- try $ Scylla.execute (Scylla.CQL "SELECT * FROM nonexistent_ks.users") [] client
388+ case result of
389+ Left _ -> pure unit
390+ Right _ -> throwError (error "Expected error for missing keyspace")
391+
392+main :: Effect Unit
393+main = launchAff_ do
394+ liftEffect $ log "\n🧪 Starting ScyllaDB Integration Tests (with Docker)\n"
395+
396+ bracket
397+ -- Start Docker before tests
398+ ( do
399+ liftEffect $ log "⏳ Starting ScyllaDB and waiting for it to be ready (may take 30-60s)..."
400+ Docker.startService "packages/yoga-scylladb/docker-compose.test.yml" 60
401+ liftEffect $ log "✅ ScyllaDB is ready!\n"
402+ )
403+ -- Stop Docker after tests (always runs!)
404+ ( \_ -> do
405+ Docker.stopService "packages/yoga-scylladb/docker-compose.test.yml"
406+ liftEffect $ log "✅ Cleanup complete\n"
407+ )
408+ -- Run tests
409+ (\_ -> runSpec [ consoleReporter ] spec)