diff --git a/bindings/go/rs_src/lib.rs b/bindings/go/rs_src/lib.rs index 035a3ac40..5d396b4e3 100644 --- a/bindings/go/rs_src/lib.rs +++ b/bindings/go/rs_src/lib.rs @@ -6,7 +6,7 @@ use std::{ ffi::{c_char, c_void}, sync::Arc, }; -use turso_core::{Connection, Database, LimboError, IO}; +use turso_core::{Connection, LimboError}; /// # Safety /// Safe to be called from Go with null terminated DSN string. @@ -20,21 +20,10 @@ pub unsafe extern "C" fn db_open(path: *const c_char) -> *mut c_void { } let path = unsafe { std::ffi::CStr::from_ptr(path) }; let path = path.to_str().unwrap(); - let io: Arc = match path { - p if p.contains(":memory:") => Arc::new(turso_core::MemoryIO::new()), - _ => Arc::new(turso_core::PlatformIO::new().expect("Failed to create IO")), + let Ok((io, conn)) = Connection::from_uri(path, false, false) else { + panic!("Failed to open connection with path: {}", path); }; - let db = Database::open_file(io.clone(), path, false, false); - match db { - Ok(db) => { - let conn = db.connect().unwrap(); - LimboConn::new(conn, io).to_ptr() - } - Err(e) => { - eprintln!("Error: {}", e); - std::ptr::null_mut() - } - } + LimboConn::new(conn, io).to_ptr() } #[allow(dead_code)] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 84d94030d..8a57b6726 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -300,29 +300,13 @@ impl Drop for Connection { #[allow(clippy::arc_with_non_send_sync)] #[pyfunction] pub fn connect(path: &str) -> Result { - #[inline(always)] - fn open_or( - io: Arc, - path: &str, - ) -> std::result::Result, PyErr> { - turso_core::Database::open_file(io, path, false, false).map_err(|e| { - PyErr::new::(format!("Failed to open database: {:?}", e)) - }) - } - - match path { - ":memory:" => { - let io: Arc = Arc::new(turso_core::MemoryIO::new()); - let db = open_or(io.clone(), path)?; - let conn: Arc = db.connect().unwrap(); - Ok(Connection { conn, io }) - } - path => { - let io: Arc = Arc::new(turso_core::PlatformIO::new()?); - let db = open_or(io.clone(), path)?; - let conn: Arc = db.connect().unwrap(); - Ok(Connection { conn, io }) - } + match turso_core::Connection::from_uri(path, false, false) { + Ok((io, conn)) => Ok(Connection { conn, io }), + Err(e) => Err(PyErr::new::(format!( + "Failed to create connection: {:?}", + e + )) + .into()), } } diff --git a/cli/app.rs b/cli/app.rs index 6108154d1..a42877bd4 100644 --- a/cli/app.rs +++ b/cli/app.rs @@ -26,7 +26,7 @@ use std::{ }; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; -use turso_core::{Database, LimboError, Statement, StepResult, Value}; +use turso_core::{Connection, Database, LimboError, OpenFlags, Statement, StepResult, Value}; #[derive(Parser)] #[command(name = "Turso")] @@ -55,6 +55,8 @@ pub struct Opts { help = "Select VFS. options are io_uring (if feature enabled), memory, and syscall" )] pub vfs: Option, + #[clap(long, help = "Open the database in read-only mode")] + pub readonly: bool, #[clap(long, help = "Enable experimental MVCC feature")] pub experimental_mvcc: bool, #[clap(long, help = "Enable experimental indexing feature")] @@ -114,32 +116,24 @@ impl Limbo { .database .as_ref() .map_or(":memory:".to_string(), |p| p.to_string_lossy().to_string()); - let (io, db) = if let Some(ref vfs) = opts.vfs { - Database::open_new(&db_file, vfs)? + let (io, conn) = if db_file.contains([':', '?', '&', '#']) { + Connection::from_uri(&db_file, opts.experimental_indexes, opts.experimental_mvcc)? } else { - let io = { - match db_file.as_str() { - ":memory:" => get_io( - DbLocation::Memory, - opts.vfs.as_ref().map_or("", |s| s.as_str()), - )?, - _path => get_io( - DbLocation::Path, - opts.vfs.as_ref().map_or("", |s| s.as_str()), - )?, - } + let flags = if opts.readonly { + OpenFlags::ReadOnly + } else { + OpenFlags::default() }; - ( - io.clone(), - Database::open_file( - io.clone(), - &db_file, - opts.experimental_mvcc, - opts.experimental_indexes, - )?, - ) + let (io, db) = Database::open_new( + &db_file, + opts.vfs.as_ref(), + flags, + opts.experimental_indexes, + opts.experimental_mvcc, + )?; + let conn = db.connect()?; + (io, conn) }; - let conn = db.connect()?; let mut ext_api = conn.build_turso_ext(); if unsafe { !limbo_completion::register_extension_static(&mut ext_api).is_ok() } { return Err(anyhow!( diff --git a/core/lib.rs b/core/lib.rs index 71836b518..82e6ba506 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -43,6 +43,7 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; use crate::storage::{header_accessor, wal::DummyWAL}; use crate::translate::optimizer::optimize_plan; +use crate::util::{OpenMode, OpenOptions}; use crate::vtab::VirtualTable; use core::str; pub use error::LimboError; @@ -274,6 +275,7 @@ impl Database { total_changes: Cell::new(0), _shared_cache: false, cache_size: Cell::new(default_cache_size), + readonly: Cell::new(false), }); if let Err(e) = conn.register_builtins() { return Err(LimboError::ExtensionError(e)); @@ -324,6 +326,7 @@ impl Database { syms: RefCell::new(SymbolTable::new()), _shared_cache: false, cache_size: Cell::new(default_cache_size), + readonly: Cell::new(false), }); if let Err(e) = conn.register_builtins() { @@ -332,29 +335,55 @@ impl Database { Ok(conn) } - /// Open a new database file with a specified VFS without an existing database + /// Open a new database file with optionally specifying a VFS without an existing database /// connection and symbol table to register extensions. #[cfg(feature = "fs")] #[allow(clippy::arc_with_non_send_sync)] - pub fn open_new(path: &str, vfs: &str) -> Result<(Arc, Arc)> { + pub fn open_new( + path: &str, + vfs: Option, + flags: OpenFlags, + indexes: bool, + mvcc: bool, + ) -> Result<(Arc, Arc)> + where + S: AsRef + std::fmt::Display, + { + use crate::util::MEMORY_PATH; let vfsmods = ext::add_builtin_vfs_extensions(None)?; - let io: Arc = match vfsmods.iter().find(|v| v.0 == vfs).map(|v| v.1.clone()) { - Some(vfs) => vfs, - None => match vfs.trim() { - "memory" => Arc::new(MemoryIO::new()), - "syscall" => Arc::new(SyscallIO::new()?), - #[cfg(all(target_os = "linux", feature = "io_uring"))] - "io_uring" => Arc::new(UringIO::new()?), - other => { - return Err(LimboError::InvalidArgument(format!( - "no such VFS: {}", - other - ))); - } - }, - }; - let db = Self::open_file(io.clone(), path, false, false)?; - Ok((io, db)) + match vfs { + Some(vfs) => { + let io: Arc = match vfsmods + .iter() + .find(|v| v.0 == vfs.as_ref()) + .map(|v| v.1.clone()) + { + Some(vfs) => vfs, + None => match vfs.as_ref() { + "memory" => Arc::new(MemoryIO::new()), + "syscall" => Arc::new(SyscallIO::new()?), + #[cfg(all(target_os = "linux", feature = "io_uring"))] + "io_uring" => Arc::new(UringIO::new()?), + other => { + return Err(LimboError::InvalidArgument(format!( + "no such VFS: {}", + other + ))); + } + }, + }; + let db = Self::open_file_with_flags(io.clone(), path, flags, indexes, mvcc)?; + Ok((io, db)) + } + None => { + let io: Arc = match path.trim() { + MEMORY_PATH => Arc::new(MemoryIO::new()), + _ => Arc::new(PlatformIO::new()?), + }; + let db = Self::open_file_with_flags(io.clone(), path, flags, indexes, mvcc)?; + Ok((io, db)) + } + } } } @@ -372,6 +401,7 @@ pub struct Connection { syms: RefCell, _shared_cache: bool, cache_size: Cell, + readonly: Cell, } impl Connection { @@ -559,6 +589,35 @@ impl Connection { Ok(()) } + #[cfg(feature = "fs")] + pub fn from_uri( + uri: &str, + use_indexes: bool, + mvcc: bool, + ) -> Result<(Arc, Arc)> { + use crate::util::MEMORY_PATH; + let opts = OpenOptions::parse(uri)?; + let flags = opts.get_flags()?; + if opts.path == MEMORY_PATH || matches!(opts.mode, OpenMode::Memory) { + let io = Arc::new(MemoryIO::new()); + let db = Database::open_file_with_flags(io.clone(), MEMORY_PATH, flags, false, false)?; + let conn = db.connect()?; + return Ok((io, conn)); + } + let (io, db) = Database::open_new(&opts.path, opts.vfs.as_ref(), flags, use_indexes, mvcc)?; + if let Some(modeof) = opts.modeof { + let perms = std::fs::metadata(modeof)?; + std::fs::set_permissions(&opts.path, perms.permissions())?; + } + let conn = db.connect()?; + conn.set_readonly(opts.immutable); + Ok((io, conn)) + } + + pub fn set_readonly(&self, readonly: bool) { + self.readonly.replace(readonly); + } + pub fn wal_frame_count(&self) -> Result { self.pager.wal_frame_count() } diff --git a/core/util.rs b/core/util.rs index 145235494..1415bcfe7 100644 --- a/core/util.rs +++ b/core/util.rs @@ -631,6 +631,8 @@ pub struct OpenOptions<'a> { pub immutable: bool, } +pub const MEMORY_PATH: &str = ":memory:"; + #[derive(Clone, Default, Debug, Copy, PartialEq)] pub enum OpenMode { ReadOnly, @@ -670,13 +672,6 @@ impl OpenMode { ))), } } - #[allow(dead_code)] - pub fn get_flags(&self) -> OpenFlags { - match self { - OpenMode::ReadWriteCreate => OpenFlags::Create, - _ => OpenFlags::None, - } - } } fn is_windows_path(path: &str) -> bool { @@ -705,58 +700,74 @@ fn normalize_windows_path(path: &str) -> String { normalized } -/// Parses a SQLite URI, handling Windows and Unix paths separately. -#[allow(dead_code)] -pub fn parse_sqlite_uri(uri: &str) -> Result { - if !uri.starts_with("file:") { - return Ok(OpenOptions { - path: uri.to_string(), - ..Default::default() - }); +impl<'a> OpenOptions<'a> { + /// Parses a SQLite URI, handling Windows and Unix paths separately. + pub fn parse(uri: &'a str) -> Result> { + if !uri.starts_with("file:") { + return Ok(OpenOptions { + path: uri.to_string(), + ..Default::default() + }); + } + + let mut opts = OpenOptions::default(); + let without_scheme = &uri[5..]; + + let (without_fragment, _) = without_scheme + .split_once('#') + .unwrap_or((without_scheme, "")); + + let (without_query, query) = without_fragment + .split_once('?') + .unwrap_or((without_fragment, "")); + parse_query_params(query, &mut opts)?; + + // handle authority + path separately + if let Some(after_slashes) = without_query.strip_prefix("//") { + let (authority, path) = after_slashes.split_once('/').unwrap_or((after_slashes, "")); + + // sqlite allows only `localhost` or empty authority. + if !(authority.is_empty() || authority == "localhost") { + return Err(LimboError::InvalidArgument(format!( + "Invalid authority '{}'. Only '' or 'localhost' allowed.", + authority + ))); + } + opts.authority = if authority.is_empty() { + None + } else { + Some(authority) + }; + + if is_windows_path(path) { + opts.path = normalize_windows_path(&decode_percent(path)); + } else if !path.is_empty() { + opts.path = format!("/{}", decode_percent(path)); + } else { + opts.path = String::new(); + } + } else { + // no authority, must be a normal absolute or relative path. + opts.path = decode_percent(without_query); + } + + Ok(opts) } - let mut opts = OpenOptions::default(); - let without_scheme = &uri[5..]; - - let (without_fragment, _) = without_scheme - .split_once('#') - .unwrap_or((without_scheme, "")); - - let (without_query, query) = without_fragment - .split_once('?') - .unwrap_or((without_fragment, "")); - parse_query_params(query, &mut opts)?; - - // handle authority + path separately - if let Some(after_slashes) = without_query.strip_prefix("//") { - let (authority, path) = after_slashes.split_once('/').unwrap_or((after_slashes, "")); - - // sqlite allows only `localhost` or empty authority. - if !(authority.is_empty() || authority == "localhost") { - return Err(LimboError::InvalidArgument(format!( - "Invalid authority '{}'. Only '' or 'localhost' allowed.", - authority - ))); + pub fn get_flags(&self) -> Result { + // Only use modeof if we're in a mode that can create files + if self.mode != OpenMode::ReadWriteCreate && self.modeof.is_some() { + return Err(LimboError::InvalidArgument( + "modeof is not applicable without mode=rwc".to_string(), + )); } - opts.authority = if authority.is_empty() { - None - } else { - Some(authority) - }; - - if is_windows_path(path) { - opts.path = normalize_windows_path(&decode_percent(path)); - } else if !path.is_empty() { - opts.path = format!("/{}", decode_percent(path)); - } else { - opts.path = String::new(); - } - } else { - // no authority, must be a normal absolute or relative path. - opts.path = decode_percent(without_query); + // If modeof is not applicable or file doesn't exist, use default flags + Ok(match self.mode { + OpenMode::ReadWriteCreate => OpenFlags::Create, + OpenMode::ReadOnly => OpenFlags::ReadOnly, + _ => OpenFlags::default(), + }) } - - Ok(opts) } // parses query parameters and updates OpenOptions @@ -1339,7 +1350,7 @@ pub mod tests { #[test] fn test_simple_uri() { let uri = "file:/home/user/db.sqlite"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.authority, None); } @@ -1347,7 +1358,7 @@ pub mod tests { #[test] fn test_uri_with_authority() { let uri = "file://localhost/home/user/db.sqlite"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.authority, Some("localhost")); } @@ -1355,14 +1366,14 @@ pub mod tests { #[test] fn test_uri_with_invalid_authority() { let uri = "file://example.com/home/user/db.sqlite"; - let result = parse_sqlite_uri(uri); + let result = OpenOptions::parse(uri); assert!(result.is_err()); } #[test] fn test_uri_with_query_params() { let uri = "file:/home/user/db.sqlite?vfs=unix&mode=ro&immutable=1"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.vfs, Some("unix".to_string())); assert_eq!(opts.mode, OpenMode::ReadOnly); @@ -1372,14 +1383,14 @@ pub mod tests { #[test] fn test_uri_with_fragment() { let uri = "file:/home/user/db.sqlite#section1"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); } #[test] fn test_uri_with_percent_encoding() { let uri = "file:/home/user/db%20with%20spaces.sqlite?vfs=unix"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db with spaces.sqlite"); assert_eq!(opts.vfs, Some("unix".to_string())); } @@ -1387,7 +1398,7 @@ pub mod tests { #[test] fn test_uri_without_scheme() { let uri = "/home/user/db.sqlite"; - let result = parse_sqlite_uri(uri); + let result = OpenOptions::parse(uri); assert!(result.is_ok()); assert_eq!(result.unwrap().path, "/home/user/db.sqlite"); } @@ -1395,7 +1406,7 @@ pub mod tests { #[test] fn test_uri_with_empty_query() { let uri = "file:/home/user/db.sqlite?"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.vfs, None); } @@ -1403,7 +1414,7 @@ pub mod tests { #[test] fn test_uri_with_partial_query() { let uri = "file:/home/user/db.sqlite?mode=rw"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.mode, OpenMode::ReadWrite); assert_eq!(opts.vfs, None); @@ -1412,14 +1423,14 @@ pub mod tests { #[test] fn test_uri_windows_style_path() { let uri = "file:///C:/Users/test/db.sqlite"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/C:/Users/test/db.sqlite"); } #[test] fn test_uri_with_only_query_params() { let uri = "file:?mode=memory&cache=shared"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, ""); assert_eq!(opts.mode, OpenMode::Memory); assert_eq!(opts.cache, CacheMode::Shared); @@ -1428,14 +1439,14 @@ pub mod tests { #[test] fn test_uri_with_only_fragment() { let uri = "file:#fragment"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, ""); } #[test] fn test_uri_with_invalid_scheme() { let uri = "http:/home/user/db.sqlite"; - let result = parse_sqlite_uri(uri); + let result = OpenOptions::parse(uri); assert!(result.is_ok()); assert_eq!(result.unwrap().path, "http:/home/user/db.sqlite"); } @@ -1443,7 +1454,7 @@ pub mod tests { #[test] fn test_uri_with_multiple_query_params() { let uri = "file:/home/user/db.sqlite?vfs=unix&mode=rw&cache=private&immutable=0"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.vfs, Some("unix".to_string())); assert_eq!(opts.mode, OpenMode::ReadWrite); @@ -1454,7 +1465,7 @@ pub mod tests { #[test] fn test_uri_with_unknown_query_param() { let uri = "file:/home/user/db.sqlite?unknown=param"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.vfs, None); } @@ -1462,7 +1473,7 @@ pub mod tests { #[test] fn test_uri_with_multiple_equal_signs() { let uri = "file:/home/user/db.sqlite?vfs=unix=custom"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.vfs, Some("unix=custom".to_string())); } @@ -1470,14 +1481,14 @@ pub mod tests { #[test] fn test_uri_with_trailing_slash() { let uri = "file:/home/user/db.sqlite/"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite/"); } #[test] fn test_uri_with_encoded_characters_in_query() { let uri = "file:/home/user/db.sqlite?vfs=unix%20mode"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/user/db.sqlite"); assert_eq!(opts.vfs, Some("unix mode".to_string())); } @@ -1485,21 +1496,21 @@ pub mod tests { #[test] fn test_uri_windows_network_path() { let uri = "file://server/share/db.sqlite"; - let result = parse_sqlite_uri(uri); + let result = OpenOptions::parse(uri); assert!(result.is_err()); // non-localhost authority should fail } #[test] fn test_uri_windows_drive_letter_with_slash() { let uri = "file:///C:/database.sqlite"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/C:/database.sqlite"); } #[test] fn test_localhost_with_double_slash_and_no_path() { let uri = "file://localhost"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, ""); assert_eq!(opts.authority, Some("localhost")); } @@ -1507,7 +1518,7 @@ pub mod tests { #[test] fn test_uri_windows_drive_letter_without_slash() { let uri = "file:///C:/database.sqlite"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/C:/database.sqlite"); } @@ -1516,11 +1527,11 @@ pub mod tests { // any other mode but ro, rwc, rw, memory should fail per sqlite let uri = "file:data.db?mode=readonly"; - let res = parse_sqlite_uri(uri); + let res = OpenOptions::parse(uri); assert!(res.is_err()); // including empty let uri = "file:/home/user/db.sqlite?vfs=&mode="; - let res = parse_sqlite_uri(uri); + let res = OpenOptions::parse(uri); assert!(res.is_err()); } @@ -1528,7 +1539,7 @@ pub mod tests { #[test] fn test_simple_file_current_dir() { let uri = "file:data.db"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "data.db"); assert_eq!(opts.authority, None); assert_eq!(opts.vfs, None); @@ -1538,7 +1549,7 @@ pub mod tests { #[test] fn test_simple_file_three_slash() { let uri = "file:///home/data/data.db"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/data/data.db"); assert_eq!(opts.authority, None); assert_eq!(opts.vfs, None); @@ -1548,7 +1559,7 @@ pub mod tests { #[test] fn test_simple_file_two_slash_localhost() { let uri = "file://localhost/home/fred/data.db"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/home/fred/data.db"); assert_eq!(opts.authority, Some("localhost")); assert_eq!(opts.vfs, None); @@ -1557,14 +1568,14 @@ pub mod tests { #[test] fn test_windows_double_invalid() { let uri = "file://C:/home/fred/data.db?mode=ro"; - let opts = parse_sqlite_uri(uri); + let opts = OpenOptions::parse(uri); assert!(opts.is_err()); } #[test] fn test_simple_file_two_slash() { let uri = "file:///C:/Documents%20and%20Settings/fred/Desktop/data.db"; - let opts = parse_sqlite_uri(uri).unwrap(); + let opts = OpenOptions::parse(uri).unwrap(); assert_eq!(opts.path, "/C:/Documents and Settings/fred/Desktop/data.db"); assert_eq!(opts.vfs, None); } diff --git a/core/vdbe/execute.rs b/core/vdbe/execute.rs index e94be0e81..b4ff7d404 100644 --- a/core/vdbe/execute.rs +++ b/core/vdbe/execute.rs @@ -4692,6 +4692,9 @@ pub fn op_open_write( else { unreachable!("unexpected Insn {:?}", insn) }; + if program.connection.readonly.get() { + return Err(LimboError::ReadOnly); + } let root_page = match root_page { RegisterOrLiteral::Literal(lit) => *lit as u64, RegisterOrLiteral::Register(reg) => match &state.registers[*reg].get_owned_value() { @@ -4794,6 +4797,9 @@ pub fn op_create_btree( let Insn::CreateBtree { db, root, flags } = insn else { unreachable!("unexpected Insn {:?}", insn) }; + if program.connection.readonly.get() { + return Err(LimboError::ReadOnly); + } if *db > 0 { // TODO: implement temp databases todo!("temp databases not implemented yet"); diff --git a/testing/cli_tests/cli_test_cases.py b/testing/cli_tests/cli_test_cases.py index 9e2aac1fb..aaf940e44 100755 --- a/testing/cli_tests/cli_test_cases.py +++ b/testing/cli_tests/cli_test_cases.py @@ -274,6 +274,20 @@ def test_insert_default_values(): limbo.quit() +def test_uri_readonly(): + turso = TestLimboShell(flags="-q file:testing/testing_small.db?mode=ro", init_commands="") + turso.run_test("read-only-uri-reads-work", "SELECT COUNT(*) FROM demo;", "5") + turso.run_test_fn( + "INSERT INTO demo (id, value) values (6, 'demo');", + lambda res: "read-only" in res, + "read-only-uri-writes-fail", + ) + turso.run_test_fn("CREATE TABLE t(a);", lambda res: "read-only" in res, "read-only-uri-cant-create-table") + turso.run_test_fn("DROP TABLE demo;", lambda res: "read-only" in res, "read-only-uri-cant-drop-table") + turso.init_test_db() + turso.quit() + + def main(): console.info("Running all Limbo CLI tests...") test_basic_queries() @@ -293,6 +307,7 @@ def main(): test_table_patterns() test_update_with_limit() test_update_with_limit_and_offset() + test_uri_readonly() console.info("All tests have passed")