diff --git a/Cargo.lock b/Cargo.lock index 540fb77d2..e7365d1e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,6 +2572,13 @@ version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +[[package]] +name = "turso-go" +version = "0.0.13" +dependencies = [ + "limbo_core", +] + [[package]] name = "typenum" version = "1.17.0" diff --git a/Cargo.toml b/Cargo.toml index 5e243c98b..570e5b638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "bindings/python", "bindings/rust", "bindings/wasm", + "bindings/go", "cli", "core", "extensions/core", diff --git a/bindings/go/Cargo.toml b/bindings/go/Cargo.toml new file mode 100644 index 000000000..98056cbe6 --- /dev/null +++ b/bindings/go/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "turso-go" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "_turso_go" +crate-type = ["cdylib"] +path = "rs_src/lib.rs" + +[features] +default = ["io_uring"] +io_uring = ["limbo_core/io_uring"] + + +[dependencies] +limbo_core = { path = "../../core/" } + +[target.'cfg(target_os = "linux")'.dependencies] +limbo_core = { path = "../../core/", features = ["io_uring"] } diff --git a/bindings/go/cmd/main.go b/bindings/go/cmd/main.go new file mode 100644 index 000000000..32fcbea23 --- /dev/null +++ b/bindings/go/cmd/main.go @@ -0,0 +1,18 @@ +// package main +// +// import ( +// "fmt" +// ) +// +// func main() { +// conn, err := lc.Open("new.db") +// if err != nil { +// panic(err) +// } +// fmt.Println("Connected to database") +// sql := "select c from t;" +// conn.Query(sql) +// +// conn.Close() +// fmt.Println("Connection closed") +// } diff --git a/bindings/go/go.mod b/bindings/go/go.mod new file mode 100644 index 000000000..fa1d99d3e --- /dev/null +++ b/bindings/go/go.mod @@ -0,0 +1,5 @@ +module turso + +go 1.23.4 + +require github.com/ebitengine/purego v0.8.2 // indirect diff --git a/bindings/go/go.sum b/bindings/go/go.sum new file mode 100644 index 000000000..38eca3dfd --- /dev/null +++ b/bindings/go/go.sum @@ -0,0 +1,2 @@ +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= diff --git a/bindings/go/rs_src/lib.rs b/bindings/go/rs_src/lib.rs new file mode 100644 index 000000000..862c8c191 --- /dev/null +++ b/bindings/go/rs_src/lib.rs @@ -0,0 +1,210 @@ +mod statement; +mod types; +use limbo_core::{Connection, Database, LimboError, Value}; +use std::{ + ffi::{c_char, c_void}, + rc::Rc, + str::FromStr, + sync::Arc, +}; + +#[no_mangle] +pub unsafe extern "C" fn db_open(path: *const c_char) -> *mut c_void { + if path.is_null() { + println!("Path is null"); + return std::ptr::null_mut(); + } + let path = unsafe { std::ffi::CStr::from_ptr(path) }; + let path = path.to_str().unwrap(); + let db_options = parse_query_str(path); + if let Ok(io) = get_io(&db_options.path) { + let db = Database::open_file(io.clone(), &db_options.path.to_string()); + match db { + Ok(db) => { + println!("Opened database: {}", path); + let conn = db.connect(); + return TursoConn::new(conn, io).to_ptr(); + } + Err(e) => { + println!("Error opening database: {}", e); + return std::ptr::null_mut(); + } + }; + } + std::ptr::null_mut() +} + +struct TursoConn<'a> { + conn: Rc, + io: Arc, + cursor_idx: usize, + cursor: Option>>, +} + +impl<'a> TursoConn<'_> { + fn new(conn: Rc, io: Arc) -> Self { + TursoConn { + conn, + io, + cursor_idx: 0, + cursor: None, + } + } + fn to_ptr(self) -> *mut c_void { + Box::into_raw(Box::new(self)) as *mut c_void + } + + fn from_ptr(ptr: *mut c_void) -> &'static mut TursoConn<'a> { + if ptr.is_null() { + panic!("Null pointer"); + } + unsafe { &mut *(ptr as *mut TursoConn) } + } +} + +/// Close the database connection +/// # Safety +/// safely frees the connection's memory +#[no_mangle] +pub unsafe extern "C" fn db_close(db: *mut c_void) { + if !db.is_null() { + let _ = unsafe { Box::from_raw(db) }; + } +} + +#[allow(clippy::arc_with_non_send_sync)] +fn get_io(db_location: &DbType) -> Result, LimboError> { + Ok(match db_location { + DbType::Memory => Arc::new(limbo_core::MemoryIO::new()?), + _ => { + #[cfg(target_family = "unix")] + if cfg!(all(target_os = "linux", feature = "io_uring")) { + Arc::new(limbo_core::UringIO::new()?) + } else { + Arc::new(limbo_core::UnixIO::new()?) + } + + #[cfg(target_family = "windows")] + Arc::new(limbo_core::WindowsIO::new()?); + } + }) +} + +struct DbOptions { + path: DbType, + params: Parameters, +} + +#[derive(Default, Debug, Clone)] +enum DbType { + File(String), + #[default] + Memory, +} + +impl std::fmt::Display for DbType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DbType::File(path) => write!(f, "{}", path), + DbType::Memory => write!(f, ":memory:"), + } + } +} + +#[derive(Debug, Clone, Default)] +struct Parameters { + mode: Mode, + cache: Option, + vfs: Option, + nolock: bool, + immutable: bool, + modeof: Option, +} + +impl FromStr for Parameters { + type Err = (); + fn from_str(s: &str) -> Result { + if !s.contains('?') { + return Ok(Parameters::default()); + } + let mut params = Parameters::default(); + for param in s.split('?').nth(1).unwrap().split('&') { + let mut kv = param.split('='); + match kv.next() { + Some("mode") => params.mode = kv.next().unwrap().parse().unwrap(), + Some("cache") => params.cache = Some(kv.next().unwrap().parse().unwrap()), + Some("vfs") => params.vfs = Some(kv.next().unwrap().to_string()), + Some("nolock") => params.nolock = true, + Some("immutable") => params.immutable = true, + Some("modeof") => params.modeof = Some(kv.next().unwrap().to_string()), + _ => {} + } + } + Ok(params) + } +} + +#[derive(Default, Debug, Clone, Copy)] +enum Cache { + Shared, + #[default] + Private, +} + +impl FromStr for Cache { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "shared" => Ok(Cache::Shared), + _ => Ok(Cache::Private), + } + } +} + +#[allow(clippy::enum_variant_names)] +#[derive(Default, Debug, Clone, Copy)] +enum Mode { + ReadOnly, + ReadWrite, + #[default] + ReadWriteCreate, +} + +impl FromStr for Mode { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "readonly" | "ro" => Ok(Mode::ReadOnly), + "readwrite" | "rw" => Ok(Mode::ReadWrite), + "readwritecreate" | "rwc" => Ok(Mode::ReadWriteCreate), + _ => Ok(Mode::default()), + } + } +} + +// At this point we don't have configurable parameters but many +// DSN's are going to have query parameters +fn parse_query_str(mut path: &str) -> DbOptions { + if path == ":memory:" { + return DbOptions { + path: DbType::Memory, + params: Parameters::default(), + }; + } + if path.starts_with("sqlite://") { + path = &path[10..]; + } + if path.contains('?') { + let parameters = Parameters::from_str(path).unwrap(); + let path = &path[..path.find('?').unwrap()]; + DbOptions { + path: DbType::File(path.to_string()), + params: parameters, + } + } else { + DbOptions { + path: DbType::File(path.to_string()), + params: Parameters::default(), + } + } +} diff --git a/bindings/go/rs_src/statement.rs b/bindings/go/rs_src/statement.rs new file mode 100644 index 000000000..99a45d692 --- /dev/null +++ b/bindings/go/rs_src/statement.rs @@ -0,0 +1,160 @@ +use crate::types::ResultCode; +use crate::TursoConn; +use limbo_core::{Rows, Statement, StepResult, Value}; +use std::ffi::{c_char, c_void}; + +#[no_mangle] +pub extern "C" fn db_prepare(ctx: *mut c_void, query: *const c_char) -> *mut c_void { + if ctx.is_null() || query.is_null() { + return std::ptr::null_mut(); + } + let query_str = unsafe { std::ffi::CStr::from_ptr(query) }.to_str().unwrap(); + + let db = TursoConn::from_ptr(ctx); + + let stmt = db.conn.prepare(query_str.to_string()); + match stmt { + Ok(stmt) => TursoStatement::new(stmt, db).to_ptr(), + Err(_) => std::ptr::null_mut(), + } +} + +struct TursoStatement<'a> { + statement: Statement, + conn: &'a TursoConn<'a>, +} + +impl<'a> TursoStatement<'a> { + fn new(statement: Statement, conn: &'a TursoConn<'a>) -> Self { + TursoStatement { statement, conn } + } + fn to_ptr(self) -> *mut c_void { + Box::into_raw(Box::new(self)) as *mut c_void + } + fn from_ptr(ptr: *mut c_void) -> &'static mut TursoStatement<'a> { + if ptr.is_null() { + panic!("Null pointer"); + } + unsafe { &mut *(ptr as *mut TursoStatement) } + } +} + +#[no_mangle] +pub extern "C" fn db_get_columns(ctx: *mut c_void) -> *const c_void { + if ctx.is_null() { + return std::ptr::null(); + } + let stmt = TursoStatement::from_ptr(ctx); + let columns = stmt.statement.columns(); + let mut column_names = Vec::new(); + for column in columns { + column_names.push(column.name().to_string()); + } + let c_string = std::ffi::CString::new(column_names.join(",")).unwrap(); + c_string.into_raw() as *const c_void +} + +struct TursoRows<'a> { + rows: Rows<'a>, + conn: &'a mut TursoConn<'a>, +} + +impl<'a> TursoRows<'a> { + fn new(rows: Rows<'a>, conn: &'a mut TursoConn<'a>) -> Self { + TursoRows { rows, conn } + } + + fn to_ptr(self) -> *mut c_void { + Box::into_raw(Box::new(self)) as *mut c_void + } + + fn from_ptr(ptr: *mut c_void) -> &'static mut TursoRows<'a> { + if ptr.is_null() { + panic!("Null pointer"); + } + unsafe { &mut *(ptr as *mut TursoRows) } + } +} + +#[no_mangle] +pub extern "C" fn rows_next(ctx: *mut c_void, rows_ptr: *mut c_void) -> ResultCode { + if rows_ptr.is_null() || ctx.is_null() { + return ResultCode::Error; + } + let rows = unsafe { &mut *(rows_ptr as *mut Rows) }; + let conn = TursoConn::from_ptr(ctx); + + match rows.next_row() { + Ok(StepResult::Row(row)) => { + conn.cursor = Some(row.values); + ResultCode::Row + } + Ok(StepResult::Done) => { + // No more rows + ResultCode::Done + } + Ok(StepResult::IO) => { + let _ = conn.io.run_once(); + ResultCode::Io + } + Ok(StepResult::Busy) => ResultCode::Busy, + Ok(StepResult::Interrupt) => ResultCode::Interrupt, + Err(_) => ResultCode::Error, + } +} + +#[no_mangle] +pub extern "C" fn rows_get_value(ctx: *mut c_void, col_idx: usize) -> *const c_char { + if ctx.is_null() { + return std::ptr::null(); + } + let conn = TursoConn::from_ptr(ctx); + + if let Some(ref cursor) = conn.cursor { + if let Some(value) = cursor.get(col_idx) { + let c_string = std::ffi::CString::new(value.to_string()).unwrap(); + return c_string.into_raw(); // Caller must free this pointer + } + } + std::ptr::null() // No data or invalid index +} + +// Free the returned string +#[no_mangle] +pub extern "C" fn free_c_string(s: *mut c_char) { + if !s.is_null() { + unsafe { drop(std::ffi::CString::from_raw(s)) }; + } +} +#[no_mangle] +pub extern "C" fn rows_get_string( + ctx: *mut c_void, + rows_ptr: *mut c_void, + col_idx: i32, +) -> *const c_char { + if rows_ptr.is_null() || ctx.is_null() { + return std::ptr::null(); + } + let _rows = unsafe { &mut *(rows_ptr as *mut Rows) }; + let conn = TursoConn::from_ptr(ctx); + if col_idx > conn.cursor_idx as i32 || conn.cursor.is_none() { + return std::ptr::null(); + } + if let Some(values) = &conn.cursor { + let value = &values[col_idx as usize]; + match value { + Value::Text(s) => { + return s.as_ptr() as *const i8; + } + _ => return std::ptr::null(), + } + }; + std::ptr::null() +} + +#[no_mangle] +pub extern "C" fn rows_close(rows_ptr: *mut c_void) { + if !rows_ptr.is_null() { + let _ = unsafe { Box::from_raw(rows_ptr as *mut Rows) }; + } +} diff --git a/bindings/go/rs_src/types.rs b/bindings/go/rs_src/types.rs new file mode 100644 index 000000000..711887229 --- /dev/null +++ b/bindings/go/rs_src/types.rs @@ -0,0 +1,14 @@ +#[repr(C)] +pub enum ResultCode { + Error = -1, + Ok = 0, + Row = 1, + Busy = 2, + Done = 3, + Io = 4, + Interrupt = 5, + Invalid = 6, + Null = 7, + NoMem = 8, + ReadOnly = 9, +} diff --git a/bindings/go/stmt.go b/bindings/go/stmt.go new file mode 100644 index 000000000..20e1a5774 --- /dev/null +++ b/bindings/go/stmt.go @@ -0,0 +1,79 @@ +package turso + +import ( + "database/sql/driver" + "fmt" + "io" +) + +type stmt struct { + ctx uintptr + sql string +} + +type rows struct { + ctx uintptr + rowsPtr uintptr + columns []string + err error +} + +func (ls *stmt) Query(args []driver.Value) (driver.Rows, error) { + var dbPrepare func(uintptr, uintptr) uintptr + getExtFunc(&dbPrepare, "db_prepare") + + queryPtr := toCString(ls.sql) + defer freeCString(queryPtr) + + rowsPtr := dbPrepare(ls.ctx, queryPtr) + if rowsPtr == 0 { + return nil, fmt.Errorf("failed to prepare query") + } + var colFunc func(uintptr, uintptr) uintptr + + getExtFunc(&colFunc, "columns") + + rows := &rows{ + ctx: ls.ctx, + rowsPtr: rowsPtr, + } + return rows, nil +} + +func (lr *rows) Columns() []string { + return lr.columns +} + +func (lr *rows) Close() error { + var rowsClose func(uintptr) + getExtFunc(&rowsClose, "rows_close") + rowsClose(lr.rowsPtr) + return nil +} + +func (lr *rows) Next(dest []driver.Value) error { + var rowsNext func(uintptr, uintptr) int32 + getExtFunc(&rowsNext, "rows_next") + + status := rowsNext(lr.ctx, lr.rowsPtr) + switch ResultCode(status) { + case Row: + for i := range dest { + getExtFunc(&rowsGetValue, "rows_get_value") + + valPtr := rowsGetValue(lr.ctx, int32(i)) + if valPtr != 0 { + val := cStringToGoString(valPtr) + dest[i] = val + freeCString(valPtr) + } else { + dest[i] = nil + } + } + return nil + case 0: // No more rows + return io.EOF + default: + return fmt.Errorf("unexpected status: %d", status) + } +} diff --git a/bindings/go/turso.go b/bindings/go/turso.go new file mode 100644 index 000000000..0c095ac80 --- /dev/null +++ b/bindings/go/turso.go @@ -0,0 +1,127 @@ +package turso + +import ( + "database/sql" + "database/sql/driver" + "errors" + "log/slog" + "os" + "sync" + "unsafe" + + "github.com/ebitengine/purego" +) + +const ( + turso = "../../target/debug/lib_turso_go.so" +) + +func toGoStr(ptr uintptr, length int) string { + if ptr == 0 { + return "" + } + uptr := unsafe.Pointer(ptr) + s := (*string)(uptr) + if s == nil { + // redundant + return "" + } + return *s +} + +func init() { + slib, err := purego.Dlopen(turso, purego.RTLD_LAZY) + if err != nil { + slog.Error("Error opening turso library: ", err) + os.Exit(1) + } + lib = slib + sql.Register("turso", &tursoDriver{}) +} + +type tursoDriver struct { + tursoCtx +} + +func toCString(s string) uintptr { + b := append([]byte(s), 0) + return uintptr(unsafe.Pointer(&b[0])) +} + +func getExtFunc(ptr interface{}, name string) { + purego.RegisterLibFunc(ptr, lib, name) +} + +type conn struct { + ctx uintptr + sync.Mutex + writeTimeFmt string + lastInsertID int64 + lastAffected int64 +} + +func newConn() *conn { + return &conn{ + 0, + sync.Mutex{}, + "2006-01-02 15:04:05", + 0, + 0, + } +} + +func open(dsn string) (*conn, error) { + var open func(uintptr) uintptr + getExtFunc(&open, ExtDBOpen) + c := newConn() + path := toCString(dsn) + ctx := open(path) + c.ctx = ctx + return c, nil +} + +type tursoCtx struct { + conn *conn + tx *sql.Tx + err error + rows *sql.Rows + stmt *sql.Stmt +} + +func (lc tursoCtx) Open(dsn string) (driver.Conn, error) { + conn, err := open(dsn) + if err != nil { + return nil, err + } + nc := tursoCtx{conn: conn} + return nc, nil +} + +func (lc tursoCtx) Close() error { + var closedb func(uintptr) uintptr + getExtFunc(&closedb, ExtDBClose) + closedb(lc.conn.ctx) + return nil +} + +// TODO: Begin not implemented +func (lc tursoCtx) Begin() (driver.Tx, error) { + return nil, nil +} + +func (ls tursoCtx) Prepare(sql string) (driver.Stmt, error) { + var prepare func(uintptr, uintptr) uintptr + getExtFunc(&prepare, ExtDBPrepare) + s := toCString(sql) + statement := prepare(ls.conn.ctx, s) + if statement == 0 { + return nil, errors.New("no rows") + } + ls.stmt = stmt{ + ctx: statement, + + } + + } + return nil, nil +} diff --git a/bindings/go/types.go b/bindings/go/types.go new file mode 100644 index 000000000..0569b317a --- /dev/null +++ b/bindings/go/types.go @@ -0,0 +1,28 @@ +package turso + +type ResultCode int + +const ( + Error ResultCode = -1 + Ok ResultCode = 0 + Row ResultCode = 1 + Busy ResultCode = 2 + Done ResultCode = 3 + Io ResultCode = 4 + Interrupt ResultCode = 5 + Invalid ResultCode = 6 + Null ResultCode = 7 + NoMem ResultCode = 8 + ReadOnly ResultCode = 9 + ExtDBOpen string = "db_open" + ExtDBClose string = "db_close" + ExtDBPrepare string = "db_prepare" +) + +var ( + lib uintptr + dbPrepare func(uintptr, uintptr) uintptr + rowsNext func(rowsPtr uintptr) int32 + rowsGetValue func(rowsPtr uintptr, colIdx uint) uintptr + freeCString func(strPtr uintptr) +)