diff --git a/backend/src/BuiltinCli/Builtin.fs b/backend/src/BuiltinCli/Builtin.fs index 629d9c26e3..b2cc79b381 100644 --- a/backend/src/BuiltinCli/Builtin.fs +++ b/backend/src/BuiltinCli/Builtin.fs @@ -15,6 +15,7 @@ let builtins = Libs.Execution.builtins Libs.Output.builtins Libs.Process.builtins + Libs.Sqlite.builtins Libs.Stdin.builtins Libs.Time.builtins Libs.Terminal.builtins ] diff --git a/backend/src/BuiltinCli/BuiltinCli.fsproj b/backend/src/BuiltinCli/BuiltinCli.fsproj index 254904ec91..463a4d95af 100644 --- a/backend/src/BuiltinCli/BuiltinCli.fsproj +++ b/backend/src/BuiltinCli/BuiltinCli.fsproj @@ -15,6 +15,7 @@ + diff --git a/backend/src/BuiltinCli/Libs/Sqlite.fs b/backend/src/BuiltinCli/Libs/Sqlite.fs new file mode 100644 index 0000000000..4a9200c5ea --- /dev/null +++ b/backend/src/BuiltinCli/Libs/Sqlite.fs @@ -0,0 +1,216 @@ +/// Standard libraries for SQLite +module BuiltinCli.Libs.Sqlite + +open System.Threading.Tasks +open FSharp.Control.Tasks +open Microsoft.Data.Sqlite +open Fumble + +open Prelude +open LibExecution.RuntimeTypes +module Dval = LibExecution.Dval +module VT = LibExecution.ValueType +module TypeChecker = LibExecution.TypeChecker +module Builtin = LibExecution.Builtin +open Builtin.Shortcuts + + +// Helper function to convert SQLite values to Dvals +let sqliteValueToDval (threadID : ThreadID) (value : obj) : Dval = + match value with + | null -> TypeChecker.DvalCreator.option threadID VT.unknownTODO None + | :? int64 as i -> DInt64 i + | :? double as d -> DFloat d + | :? string as s -> DString s + | :? System.Boolean as b -> DBool b + | :? (byte array) as bytes -> + DList( + ValueType.Known KTUInt8, + bytes |> Array.map (fun b -> DUInt8 b) |> Array.toList + ) + | _ -> DString(value.ToString()) + + +// Helper function to convert a row to a Dict +let rowToDict (threadID : ThreadID) (reader : SqliteDataReader) : Dval = + let mutable fields = Map.empty + + for i in 0 .. reader.FieldCount - 1 do + let columnName = reader.GetName(i) + let value = reader.GetValue(i) + let dval = sqliteValueToDval threadID value + fields <- Map.add columnName dval fields + + DDict(ValueType.Unknown, fields) + + +let fns : List = + [ { name = fn "sqliteQuery" 0 + typeParams = [] + parameters = + [ Param.make "path" TString ""; Param.make "sql" TString "" ] + returnType = TypeReference.result (TList(TDict TString)) TString + description = + "Executes a SELECT query against the SQLite database at and returns the results as a list of dictionaries. Each dictionary represents a row with column names as keys." + fn = + (function + | _, vmState, _, [ DString path; DString sql ] -> + uply { + try + let path = + path.Replace( + "$HOME", + System.Environment.GetEnvironmentVariable "HOME" + ) + + let connString = $"Data Source={path};Mode=ReadWrite" + use connection = new SqliteConnection(connString) + do! connection.OpenAsync() + + use command = connection.CreateCommand() + command.CommandText <- sql + + use! reader = command.ExecuteReaderAsync() + let mutable results = [] + + let rec readRows () = + uply { + let! hasRow = reader.ReadAsync() + + if hasRow then + let row = rowToDict vmState.threadID reader + results <- row :: results + return! readRows () + else + return () + } + + do! readRows () + + let listResult = + DList(ValueType.Known(KTDict(ValueType.Unknown)), List.rev results) + + return + Dval.resultOk + (KTList(ValueType.Known(KTDict(ValueType.Unknown)))) + KTString + listResult + with e -> + return + Dval.resultError + (KTList(ValueType.Known(KTDict(ValueType.Unknown)))) + KTString + (DString($"SQLite query error: {e.Message}")) + } + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Impure + deprecated = NotDeprecated } + + + { name = fn "sqliteQueryOne" 0 + typeParams = [] + parameters = + [ Param.make "path" TString ""; Param.make "sql" TString "" ] + returnType = TypeReference.result (TypeReference.option (TDict TString)) TString + description = + "Executes a SELECT query against the SQLite database at and returns the first row as an Option of dictionary. Returns None if no rows match." + fn = + (function + | _, vmState, _, [ DString path; DString sql ] -> + uply { + try + let path = + path.Replace( + "$HOME", + System.Environment.GetEnvironmentVariable "HOME" + ) + + let connString = $"Data Source={path};Mode=ReadWrite" + use connection = new SqliteConnection(connString) + do! connection.OpenAsync() + + use command = connection.CreateCommand() + command.CommandText <- sql + + use! reader = command.ExecuteReaderAsync() + + let! hasRow = reader.ReadAsync() + + let dictType = VT.dict VT.unknownTODO + + let optResult = + if hasRow then + let row = rowToDict vmState.threadID reader + TypeChecker.DvalCreator.optionSome vmState.threadID dictType row + else + TypeChecker.DvalCreator.optionNone dictType + + // Manually construct Result.Ok enum containing the option + return + DEnum( + Dval.resultType, + Dval.resultType, + [ VT.unknownTODO; VT.string ], + "Ok", + [ optResult ] + ) + with e -> + // Manually construct Result.Error enum + return + DEnum( + Dval.resultType, + Dval.resultType, + [ VT.unknownTODO; VT.string ], + "Error", + [ DString($"SQLite query error: {e.Message}") ] + ) + } + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Impure + deprecated = NotDeprecated } + + + { name = fn "sqliteExecute" 0 + typeParams = [] + parameters = + [ Param.make "path" TString ""; Param.make "sql" TString "" ] + returnType = TypeReference.result TInt64 TString + description = + "Executes a SQL statement (INSERT, UPDATE, DELETE, CREATE, etc.) against the SQLite database at . Returns the number of rows affected." + fn = + (function + | _, _, _, [ DString path; DString sql ] -> + uply { + try + let path = + path.Replace( + "$HOME", + System.Environment.GetEnvironmentVariable "HOME" + ) + + let connString = $"Data Source={path};Mode=ReadWriteCreate" + use connection = new SqliteConnection(connString) + do! connection.OpenAsync() + + use command = connection.CreateCommand() + command.CommandText <- sql + + let! affectedRows = command.ExecuteNonQueryAsync() + let result = DInt64(int64 affectedRows) + return Dval.resultOk KTInt64 KTString result + with e -> + return + Dval.resultError + KTInt64 + KTString + (DString($"SQLite execute error: {e.Message}")) + } + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Impure + deprecated = NotDeprecated } ] + + +let builtins : Builtins = Builtin.make [] fns diff --git a/backend/src/BuiltinCli/paket.references b/backend/src/BuiltinCli/paket.references index 4b5bc5d60f..d8c5d9e883 100644 --- a/backend/src/BuiltinCli/paket.references +++ b/backend/src/BuiltinCli/paket.references @@ -1,3 +1,5 @@ Ply FSharp.Core -FSharpPlus \ No newline at end of file +FSharpPlus +Microsoft.Data.Sqlite +Fumble \ No newline at end of file diff --git a/packages/darklang/cli/tests/sqlite.dark b/packages/darklang/cli/tests/sqlite.dark new file mode 100644 index 0000000000..0f794d55bf --- /dev/null +++ b/packages/darklang/cli/tests/sqlite.dark @@ -0,0 +1,288 @@ +module Darklang.Cli.Tests.Sqlite + +type TestResult = + | Pass + | Fail of message: String + + +let testCreateTableAndInsert (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Create a table + let createResult = + Stdlib.Cli.Sqlite.execute + tempPath + "CREATE TABLE test_users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)" + + match createResult with + | Error msg -> TestResult.Fail $"Failed to create table: {msg}" + | Ok rowCount -> + // Insert some data + let insert1 = + Stdlib.Cli.Sqlite.execute + tempPath + "INSERT INTO test_users (id, name, age) VALUES (1, 'Alice', 30)" + + let insert2 = + Stdlib.Cli.Sqlite.execute + tempPath + "INSERT INTO test_users (id, name, age) VALUES (2, 'Bob', 25)" + + match (insert1, insert2) with + | (Ok count1, Ok count2) -> + if count1 == 1L && count2 == 1L then + // Query the data + let queryResult = + Stdlib.Cli.Sqlite.query tempPath "SELECT * FROM test_users ORDER BY id" + + match queryResult with + | Error msg -> TestResult.Fail $"Failed to query: {msg}" + | Ok rows -> + let rowCount = Stdlib.List.length rows + + if rowCount == 2L then + TestResult.Pass + else + TestResult.Fail $"Expected 2 rows, got {rowCount}" + else + TestResult.Fail $"Expected 1 row affected per insert, got {count1} and {count2}" + | (Error msg, _) -> TestResult.Fail $"Insert 1 failed: {msg}" + | (_, Error msg) -> TestResult.Fail $"Insert 2 failed: {msg}" + + +let testQueryOne (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Create table and insert data + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "CREATE TABLE test_data (id INTEGER, value TEXT)" + + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "INSERT INTO test_data VALUES (1, 'first')" + + // Query for existing row + let queryResult = + Stdlib.Cli.Sqlite.queryOne + tempPath + "SELECT * FROM test_data WHERE id = 1" + + match queryResult with + | Error msg -> TestResult.Fail $"Query failed: {msg}" + | Ok optRow -> + match optRow with + | None -> TestResult.Fail "Expected Some row, got None" + | Some row -> + // Query for non-existing row + let queryResult2 = + Stdlib.Cli.Sqlite.queryOne + tempPath + "SELECT * FROM test_data WHERE id = 999" + + match queryResult2 with + | Error msg -> TestResult.Fail $"Query 2 failed: {msg}" + | Ok optRow2 -> + match optRow2 with + | Some _ -> TestResult.Fail "Expected None, got Some row" + | None -> TestResult.Pass + + +let testUpdate (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Create table and insert data + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "CREATE TABLE test_update (id INTEGER, name TEXT)" + + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "INSERT INTO test_update VALUES (1, 'original')" + + // Update the row + let updateResult = + Stdlib.Cli.Sqlite.update tempPath "test_update" "name = 'updated'" "id = 1" + + match updateResult with + | Error msg -> TestResult.Fail $"Update failed: {msg}" + | Ok count -> + if count == 1L then + // Verify the update + let queryResult = + Stdlib.Cli.Sqlite.queryOne + tempPath + "SELECT name FROM test_update WHERE id = 1" + + match queryResult with + | Error msg -> TestResult.Fail $"Query failed: {msg}" + | Ok optRow -> + match optRow with + | None -> TestResult.Fail "Expected row after update" + | Some row -> + let name = Stdlib.Dict.get row "name" + + match name with + | Some nameVal -> + if nameVal == "updated" then + TestResult.Pass + else + TestResult.Fail $"Expected 'updated', got {nameVal}" + | None -> TestResult.Fail "Name column not found" + else + TestResult.Fail $"Expected 1 row updated, got {count}" + + +let testDelete (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Create table and insert data + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "CREATE TABLE test_delete (id INTEGER, name TEXT)" + + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "INSERT INTO test_delete VALUES (1, 'to_delete')" + + let _ = + Stdlib.Cli.Sqlite.execute + tempPath + "INSERT INTO test_delete VALUES (2, 'to_keep')" + + // Delete one row + let deleteResult = + Stdlib.Cli.Sqlite.delete tempPath "test_delete" "id = 1" + + match deleteResult with + | Error msg -> TestResult.Fail $"Delete failed: {msg}" + | Ok count -> + if count == 1L then + // Verify only one row remains + let queryResult = + Stdlib.Cli.Sqlite.query tempPath "SELECT * FROM test_delete" + + match queryResult with + | Error msg -> TestResult.Fail $"Query failed: {msg}" + | Ok rows -> + let rowCount = Stdlib.List.length rows + + if rowCount == 1L then + TestResult.Pass + else + TestResult.Fail $"Expected 1 row remaining, got {rowCount}" + else + TestResult.Fail $"Expected 1 row deleted, got {count}" + + +let testHelperFunctions (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Test createTable helper + let createResult = + Stdlib.Cli.Sqlite.createTable + tempPath + "helpers_test" + "id INTEGER PRIMARY KEY, name TEXT" + + match createResult with + | Error msg -> TestResult.Fail $"createTable failed: {msg}" + | Ok _ -> + // Test insert helper + let insertResult = + Stdlib.Cli.Sqlite.insert tempPath "helpers_test" "id, name" "1, 'test'" + + match insertResult with + | Error msg -> TestResult.Fail $"insert failed: {msg}" + | Ok count -> + if count == 1L then + TestResult.Pass + else + TestResult.Fail $"Expected 1 row inserted, got {count}" + + +let testInvalidSql (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Try to execute invalid SQL + let result = + Stdlib.Cli.Sqlite.execute tempPath "THIS IS NOT VALID SQL" + + match result with + | Error msg -> + // We expect an error - check that it contains "SQLite" or "error" + if Stdlib.String.contains msg "SQLite" || + Stdlib.String.contains msg "error" || + Stdlib.String.contains msg "syntax" then + TestResult.Pass + else + TestResult.Fail $"Expected SQLite error message, got: {msg}" + | Ok _ -> TestResult.Fail "Expected error for invalid SQL, but got success" + + +let testFileNotFound (): TestResult = + // Try to query a non-existent database file + let result = + Stdlib.Cli.Sqlite.query "/tmp/this-file-definitely-does-not-exist-12345.db" "SELECT 1" + + match result with + | Error msg -> + // We expect an error - check that it mentions the issue + if Stdlib.String.contains msg "SQLite" || Stdlib.String.contains msg "error" then + TestResult.Pass + else + TestResult.Fail $"Expected SQLite error message, got: {msg}" + | Ok _ -> TestResult.Fail "Expected error for non-existent file, but got success" + + +let testInvalidTable (): TestResult = + // Use a temporary file for testing + let tempResult = Builtin.fileCreateTemp () + + match tempResult with + | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}" + | Ok tempPath -> + // Try to query a table that doesn't exist + let result = + Stdlib.Cli.Sqlite.query tempPath "SELECT * FROM nonexistent_table" + + match result with + | Error msg -> + // We expect an error about the table not existing + if Stdlib.String.contains msg "no such table" || + Stdlib.String.contains msg "nonexistent" || + Stdlib.String.contains msg "error" then + TestResult.Pass + else + TestResult.Fail $"Expected 'no such table' error, got: {msg}" + | Ok _ -> TestResult.Fail "Expected error for non-existent table, but got success" diff --git a/packages/darklang/stdlib/cli/sqlite.dark b/packages/darklang/stdlib/cli/sqlite.dark new file mode 100644 index 0000000000..f1deaf3688 --- /dev/null +++ b/packages/darklang/stdlib/cli/sqlite.dark @@ -0,0 +1,118 @@ +module Darklang.Stdlib.Cli.Sqlite + +/// Executes a SQL statement (INSERT, UPDATE, DELETE, CREATE, etc.) against +/// the SQLite database at the specified path. Returns the number of rows affected +/// wrapped in a Result. +/// +/// Example: +/// Stdlib.Cli.Sqlite.execute "test.db" "CREATE TABLE users (id INTEGER, name TEXT)" +/// // Result.Ok 0L +/// +/// Stdlib.Cli.Sqlite.execute "test.db" "INSERT INTO users VALUES (1, 'Alice')" +/// // Result.Ok 1L +let execute (path: String) (sql: String) : Stdlib.Result.Result = + Builtin.sqliteExecute path sql + + +/// Executes a SELECT query against the SQLite database at the specified path +/// and returns all matching rows as a list of dictionaries. Each dictionary +/// represents a row with column names as keys. +/// +/// Example: +/// Stdlib.Cli.Sqlite.query "test.db" "SELECT * FROM users" +/// // Result.Ok [ { "id": 1L, "name": "Alice" }, { "id": 2L, "name": "Bob" } ] +let query + (path: String) + (sql: String) + : Stdlib.Result.Result>, String> = + Builtin.sqliteQuery path sql + + +/// Executes a SELECT query against the SQLite database at the specified path +/// and returns the first matching row as an Option of dictionary. Returns None +/// if no rows match. +/// +/// Example: +/// Stdlib.Cli.Sqlite.queryOne "test.db" "SELECT * FROM users WHERE id = 1" +/// // Result.Ok (Some { "id": 1L, "name": "Alice" }) +/// +/// Stdlib.Cli.Sqlite.queryOne "test.db" "SELECT * FROM users WHERE id = 999" +/// // Result.Ok None +let queryOne + (path: String) + (sql: String) + : Stdlib.Result.Result>, String> = + Builtin.sqliteQueryOne path sql + + +/// Creates a new table with the specified name and columns. +/// This is a helper function that wraps the execute function. +/// +/// Example: +/// Stdlib.Cli.Sqlite.createTable +/// "test.db" +/// "users" +/// "id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT" +/// // Result.Ok 0L +let createTable + (path: String) + (tableName: String) + (columns: String) + : Stdlib.Result.Result = + let sql = $"CREATE TABLE IF NOT EXISTS {tableName} ({columns})" + execute path sql + + +/// Inserts a row into the specified table. +/// This is a helper function that wraps the execute function. +/// +/// Example: +/// Stdlib.Cli.Sqlite.insert +/// "test.db" +/// "users" +/// "name, email" +/// "'Alice', 'alice@example.com'" +/// // Result.Ok 1L +let insert + (path: String) + (tableName: String) + (columns: String) + (values: String) + : Stdlib.Result.Result = + let sql = $"INSERT INTO {tableName} ({columns}) VALUES ({values})" + execute path sql + + +/// Updates rows in the specified table that match the given condition. +/// This is a helper function that wraps the execute function. +/// +/// Example: +/// Stdlib.Cli.Sqlite.update +/// "test.db" +/// "users" +/// "email = 'newemail@example.com'" +/// "id = 1" +/// // Result.Ok 1L +let update + (path: String) + (tableName: String) + (setClause: String) + (whereClause: String) + : Stdlib.Result.Result = + let sql = $"UPDATE {tableName} SET {setClause} WHERE {whereClause}" + execute path sql + + +/// Deletes rows from the specified table that match the given condition. +/// This is a helper function that wraps the execute function. +/// +/// Example: +/// Stdlib.Cli.Sqlite.delete "test.db" "users" "id = 1" +/// // Result.Ok 1L +let delete + (path: String) + (tableName: String) + (whereClause: String) + : Stdlib.Result.Result = + let sql = $"DELETE FROM {tableName} WHERE {whereClause}" + execute path sql