From cc8c7639424de7732515aed91a3f5fc4214226ca Mon Sep 17 00:00:00 2001 From: Avinash Sajjanshetty Date: Thu, 21 Aug 2025 22:22:43 +0530 Subject: [PATCH 01/12] refactor encryption module and make it configurable --- core/storage/encryption.rs | 279 ++++++++++++++++++++++++------------- 1 file changed, 184 insertions(+), 95 deletions(-) diff --git a/core/storage/encryption.rs b/core/storage/encryption.rs index 97c2b3574..8c8f7bd44 100644 --- a/core/storage/encryption.rs +++ b/core/storage/encryption.rs @@ -1,7 +1,5 @@ #![allow(unused_variables, dead_code)] -#[cfg(not(feature = "encryption"))] -use crate::LimboError; -use crate::Result; +use crate::{LimboError, Result}; use aes_gcm::{ aead::{Aead, AeadCore, KeyInit, OsRng}, Aes256Gcm, Key, Nonce, @@ -11,6 +9,7 @@ use std::ops::Deref; pub const ENCRYPTION_METADATA_SIZE: usize = 28; pub const ENCRYPTED_PAGE_SIZE: usize = 4096; pub const ENCRYPTION_NONCE_SIZE: usize = 12; +pub const ENCRYPTION_TAG_SIZE: usize = 16; #[repr(transparent)] #[derive(Clone)] @@ -71,106 +70,195 @@ impl Drop for EncryptionKey { } } -#[cfg(not(feature = "encryption"))] -pub fn encrypt_page(page: &[u8], page_id: usize, key: &EncryptionKey) -> Result> { - Err(LimboError::InvalidArgument( - "encryption is not enabled, cannot encrypt page. enable via passing `--features encryption`".into(), - )) +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CipherMode { + Aes256Gcm, } -#[cfg(feature = "encryption")] -pub fn encrypt_page(page: &[u8], page_id: usize, key: &EncryptionKey) -> Result> { - if page_id == 1 { - tracing::debug!("skipping encryption for page 1 (database header)"); - return Ok(page.to_vec()); +impl CipherMode { + /// Every cipher requires a specific key size. For 256-bit algorithms, this is 32 bytes. + /// For 128-bit algorithms, it would be 16 bytes, etc. + pub fn required_key_size(&self) -> usize { + match self { + CipherMode::Aes256Gcm => 32, + } } - tracing::debug!("encrypting page {}", page_id); - assert_eq!( - page.len(), - ENCRYPTED_PAGE_SIZE, - "Page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" - ); - let reserved_bytes = &page[ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE..]; - let reserved_bytes_zeroed = reserved_bytes.iter().all(|&b| b == 0); - assert!( - reserved_bytes_zeroed, - "last reserved bytes must be empty/zero, but found non-zero bytes" - ); - let payload = &page[..ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE]; - let (encrypted, nonce) = encrypt(payload, key)?; - assert_eq!( - encrypted.len(), - ENCRYPTED_PAGE_SIZE - nonce.len(), - "Encrypted page must be exactly {} bytes", - ENCRYPTED_PAGE_SIZE - nonce.len() - ); - let mut result = Vec::with_capacity(ENCRYPTED_PAGE_SIZE); - result.extend_from_slice(&encrypted); - result.extend_from_slice(&nonce); - assert_eq!( - result.len(), - ENCRYPTED_PAGE_SIZE, - "Encrypted page must be exactly {ENCRYPTED_PAGE_SIZE} bytes" - ); - Ok(result) -} -#[cfg(not(feature = "encryption"))] -pub fn decrypt_page(encrypted_page: &[u8], page_id: usize, key: &EncryptionKey) -> Result> { - Err(LimboError::InvalidArgument( - "encryption is not enabled, cannot decrypt page. enable via passing `--features encryption`".into(), - )) -} - -#[cfg(feature = "encryption")] -pub fn decrypt_page(encrypted_page: &[u8], page_id: usize, key: &EncryptionKey) -> Result> { - if page_id == 1 { - tracing::debug!("skipping decryption for page 1 (database header)"); - return Ok(encrypted_page.to_vec()); + /// Returns the nonce size for this cipher mode. Though most AEAD ciphers use 12-byte nonces. + pub fn nonce_size(&self) -> usize { + match self { + CipherMode::Aes256Gcm => ENCRYPTION_NONCE_SIZE, + } } - tracing::debug!("decrypting page {}", page_id); - assert_eq!( - encrypted_page.len(), - ENCRYPTED_PAGE_SIZE, - "Encrypted page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" - ); - let nonce_start = encrypted_page.len() - ENCRYPTION_NONCE_SIZE; - let payload = &encrypted_page[..nonce_start]; - let nonce = &encrypted_page[nonce_start..]; - - let decrypted_data = decrypt(payload, nonce, key)?; - assert_eq!( - decrypted_data.len(), - ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE, - "Decrypted page data must be exactly {} bytes", - ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE - ); - let mut result = Vec::with_capacity(ENCRYPTED_PAGE_SIZE); - result.extend_from_slice(&decrypted_data); - result.resize(ENCRYPTED_PAGE_SIZE, 0); - assert_eq!( - result.len(), - ENCRYPTED_PAGE_SIZE, - "Decrypted page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" - ); - Ok(result) + /// Returns the authentication tag size for this cipher mode. All common AEAD ciphers use 16-byte tags. + pub fn tag_size(&self) -> usize { + match self { + CipherMode::Aes256Gcm => ENCRYPTION_TAG_SIZE, + } + } } -fn encrypt(plaintext: &[u8], key: &EncryptionKey) -> Result<(Vec, Vec)> { - let key: &Key = key.as_ref().into(); - let cipher = Aes256Gcm::new(key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher.encrypt(&nonce, plaintext).unwrap(); - Ok((ciphertext, nonce.to_vec())) +#[derive(Clone)] +pub enum Cipher { + Aes256Gcm(Box), } -fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &EncryptionKey) -> Result> { - let key: &Key = key.as_ref().into(); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(nonce); - let plaintext = cipher.decrypt(nonce, ciphertext).unwrap(); - Ok(plaintext) +impl std::fmt::Debug for Cipher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Cipher::Aes256Gcm(_) => write!(f, "Cipher::Aes256Gcm"), + } + } +} + +#[derive(Clone)] +pub struct PerConnEncryptionContext { + cipher_mode: CipherMode, + cipher: Cipher, +} + +impl PerConnEncryptionContext { + pub fn new(key: &EncryptionKey) -> Result { + let cipher_mode = CipherMode::Aes256Gcm; + let required_size = cipher_mode.required_key_size(); + if key.as_slice().len() != required_size { + return Err(crate::LimboError::InvalidArgument(format!( + "Invalid key size for {:?}: expected {} bytes, got {}", + cipher_mode, + required_size, + key.as_slice().len() + ))); + } + + let cipher = match cipher_mode { + CipherMode::Aes256Gcm => { + let cipher_key: &Key = key.as_ref().into(); + Cipher::Aes256Gcm(Box::new(Aes256Gcm::new(cipher_key))) + } + }; + Ok(Self { + cipher_mode, + cipher, + }) + } + + pub fn cipher_mode(&self) -> CipherMode { + self.cipher_mode + } + + #[cfg(feature = "encryption")] + pub fn encrypt_page(&self, page: &[u8], page_id: usize) -> Result> { + if page_id == 1 { + tracing::debug!("skipping encryption for page 1 (database header)"); + return Ok(page.to_vec()); + } + tracing::debug!("encrypting page {}", page_id); + assert_eq!( + page.len(), + ENCRYPTED_PAGE_SIZE, + "Page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + let reserved_bytes = &page[ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE..]; + let reserved_bytes_zeroed = reserved_bytes.iter().all(|&b| b == 0); + assert!( + reserved_bytes_zeroed, + "last reserved bytes must be empty/zero, but found non-zero bytes" + ); + let payload = &page[..ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE]; + let (encrypted, nonce) = self.encrypt_raw(payload)?; + + assert_eq!( + encrypted.len(), + ENCRYPTED_PAGE_SIZE - nonce.len(), + "Encrypted page must be exactly {} bytes", + ENCRYPTED_PAGE_SIZE - nonce.len() + ); + let mut result = Vec::with_capacity(ENCRYPTED_PAGE_SIZE); + result.extend_from_slice(&encrypted); + result.extend_from_slice(&nonce); + assert_eq!( + result.len(), + ENCRYPTED_PAGE_SIZE, + "Encrypted page must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + Ok(result) + } + + #[cfg(feature = "encryption")] + pub fn decrypt_page(&self, encrypted_page: &[u8], page_id: usize) -> Result> { + if page_id == 1 { + tracing::debug!("skipping decryption for page 1 (database header)"); + return Ok(encrypted_page.to_vec()); + } + tracing::debug!("decrypting page {}", page_id); + assert_eq!( + encrypted_page.len(), + ENCRYPTED_PAGE_SIZE, + "Encrypted page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + + let nonce_start = encrypted_page.len() - ENCRYPTION_NONCE_SIZE; + let payload = &encrypted_page[..nonce_start]; + let nonce = &encrypted_page[nonce_start..]; + + let decrypted_data = self.decrypt_raw(payload, nonce)?; + + assert_eq!( + decrypted_data.len(), + ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE, + "Decrypted page data must be exactly {} bytes", + ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE + ); + let mut result = Vec::with_capacity(ENCRYPTED_PAGE_SIZE); + result.extend_from_slice(&decrypted_data); + result.resize(ENCRYPTED_PAGE_SIZE, 0); + assert_eq!( + result.len(), + ENCRYPTED_PAGE_SIZE, + "Decrypted page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + Ok(result) + } + + /// encrypts raw data using the configured cipher, returns ciphertext and nonce + fn encrypt_raw(&self, plaintext: &[u8]) -> Result<(Vec, Vec)> { + match &self.cipher { + Cipher::Aes256Gcm(cipher) => { + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| LimboError::InternalError(format!("Encryption failed: {e:?}")))?; + Ok((ciphertext, nonce.to_vec())) + } + } + } + + fn decrypt_raw(&self, ciphertext: &[u8], nonce: &[u8]) -> Result> { + match &self.cipher { + Cipher::Aes256Gcm(cipher) => { + let nonce = Nonce::from_slice(nonce); + let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| { + crate::LimboError::InternalError(format!("Decryption failed: {e:?}")) + })?; + Ok(plaintext) + } + } + } + + #[cfg(not(feature = "encryption"))] + pub fn encrypt_page(&self, _page: &[u8], _page_id: usize) -> Result> { + Err(LimboError::InvalidArgument( + "encryption is not enabled, cannot encrypt page. enable via passing `--features encryption`".into(), + )) + } + + #[cfg(not(feature = "encryption"))] + pub fn decrypt_page(&self, _encrypted_page: &[u8], _page_id: usize) -> Result> { + Err(LimboError::InvalidArgument( + "encryption is not enabled, cannot decrypt page. enable via passing `--features encryption`".into(), + )) + } } #[cfg(test)] @@ -193,14 +281,15 @@ mod tests { }; let key = EncryptionKey::from_string("alice and bob use encryption on database"); + let ctx = PerConnEncryptionContext::new(&key).unwrap(); let page_id = 42; - let encrypted = encrypt_page(&page_data, page_id, &key).unwrap(); + let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap(); assert_eq!(encrypted.len(), ENCRYPTED_PAGE_SIZE); assert_ne!(&encrypted[..data_size], &page_data[..data_size]); assert_ne!(&encrypted[..], &page_data[..]); - let decrypted = decrypt_page(&encrypted, page_id, &key).unwrap(); + let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap(); assert_eq!(decrypted.len(), ENCRYPTED_PAGE_SIZE); assert_eq!(decrypted, page_data); } From 3090545167ee2ed60cdc97a764e629c0ebb541c5 Mon Sep 17 00:00:00 2001 From: Avinash Sajjanshetty Date: Thu, 21 Aug 2025 22:23:08 +0530 Subject: [PATCH 02/12] use encryption ctx instead of encryption key --- bindings/javascript/src/lib.rs | 6 +++--- core/lib.rs | 8 ++++---- core/storage/database.rs | 36 +++++++++++++++++++--------------- core/storage/pager.rs | 23 ++++++++++++---------- core/storage/sqlite3_ondisk.rs | 6 +++--- core/storage/wal.rs | 28 +++++++++++++------------- core/translate/pragma.rs | 2 +- 7 files changed, 58 insertions(+), 51 deletions(-) diff --git a/bindings/javascript/src/lib.rs b/bindings/javascript/src/lib.rs index 1c0218eeb..7cd1a6965 100644 --- a/bindings/javascript/src/lib.rs +++ b/bindings/javascript/src/lib.rs @@ -561,7 +561,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { fn read_page( &self, page_idx: usize, - _key: Option<&turso_core::EncryptionKey>, + _encryption_ctx: Option<&turso_core::PerConnEncryptionContext>, c: turso_core::Completion, ) -> turso_core::Result { let r = c.as_read(); @@ -578,7 +578,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { &self, page_idx: usize, buffer: Arc, - _key: Option<&turso_core::EncryptionKey>, + _encryption_ctx: Option<&turso_core::PerConnEncryptionContext>, c: turso_core::Completion, ) -> turso_core::Result { let size = buffer.len(); @@ -591,7 +591,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { first_page_idx: usize, page_size: usize, buffers: Vec>, - _key: Option<&turso_core::EncryptionKey>, + _encryption_ctx: Option<&turso_core::PerConnEncryptionContext>, c: turso_core::Completion, ) -> turso_core::Result { let pos = first_page_idx.saturating_sub(1) * page_size; diff --git a/core/lib.rs b/core/lib.rs index 7162152ed..89ef7f4f2 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -76,7 +76,7 @@ use std::{ }; #[cfg(feature = "fs")] use storage::database::DatabaseFile; -pub use storage::encryption::EncryptionKey; +pub use storage::encryption::{EncryptionKey, PerConnEncryptionContext}; use storage::page_cache::DumbLruPageCache; use storage::pager::{AtomicDbState, DbState}; use storage::sqlite3_ondisk::PageSize; @@ -1904,11 +1904,11 @@ impl Connection { self.syms.borrow().vtab_modules.keys().cloned().collect() } - pub fn set_encryption_key(&self, key: Option) { + pub fn set_encryption_key(&self, key: EncryptionKey) { tracing::trace!("setting encryption key for connection"); - *self.encryption_key.borrow_mut() = key.clone(); + *self.encryption_key.borrow_mut() = Some(key.clone()); let pager = self.pager.borrow(); - pager.set_encryption_key(key); + pager.set_encryption_context(&key); } } diff --git a/core/storage/database.rs b/core/storage/database.rs index 980dce8e4..e2b27b302 100644 --- a/core/storage/database.rs +++ b/core/storage/database.rs @@ -1,5 +1,5 @@ use crate::error::LimboError; -use crate::storage::encryption::{decrypt_page, encrypt_page, EncryptionKey}; +use crate::storage::encryption::PerConnEncryptionContext; use crate::{io::Completion, Buffer, CompletionError, Result}; use std::sync::Arc; use tracing::{instrument, Level}; @@ -15,14 +15,14 @@ pub trait DatabaseStorage: Send + Sync { fn read_page( &self, page_idx: usize, - encryption_key: Option<&EncryptionKey>, + encryption_ctx: Option<&PerConnEncryptionContext>, c: Completion, ) -> Result; fn write_page( &self, page_idx: usize, buffer: Arc, - encryption_key: Option<&EncryptionKey>, + encryption_ctx: Option<&PerConnEncryptionContext>, c: Completion, ) -> Result; fn write_pages( @@ -30,7 +30,7 @@ pub trait DatabaseStorage: Send + Sync { first_page_idx: usize, page_size: usize, buffers: Vec>, - encryption_key: Option<&EncryptionKey>, + encryption_ctx: Option<&PerConnEncryptionContext>, c: Completion, ) -> Result; fn sync(&self, c: Completion) -> Result; @@ -59,7 +59,7 @@ impl DatabaseStorage for DatabaseFile { fn read_page( &self, page_idx: usize, - encryption_key: Option<&EncryptionKey>, + encryption_ctx: Option<&PerConnEncryptionContext>, c: Completion, ) -> Result { let r = c.as_read(); @@ -70,8 +70,8 @@ impl DatabaseStorage for DatabaseFile { } let pos = (page_idx - 1) * size; - if let Some(key) = encryption_key { - let key_clone = key.clone(); + if let Some(ctx) = encryption_ctx { + let encryption_ctx = ctx.clone(); let read_buffer = r.buf_arc(); let original_c = c.clone(); @@ -81,7 +81,7 @@ impl DatabaseStorage for DatabaseFile { return; }; if bytes_read > 0 { - match decrypt_page(buf.as_slice(), page_idx, &key_clone) { + match encryption_ctx.decrypt_page(buf.as_slice(), page_idx) { Ok(decrypted_data) => { let original_buf = original_c.as_read().buf(); original_buf.as_mut_slice().copy_from_slice(&decrypted_data); @@ -111,7 +111,7 @@ impl DatabaseStorage for DatabaseFile { &self, page_idx: usize, buffer: Arc, - encryption_key: Option<&EncryptionKey>, + encryption_ctx: Option<&PerConnEncryptionContext>, c: Completion, ) -> Result { let buffer_size = buffer.len(); @@ -121,8 +121,8 @@ impl DatabaseStorage for DatabaseFile { assert_eq!(buffer_size & (buffer_size - 1), 0); let pos = (page_idx - 1) * buffer_size; let buffer = { - if let Some(key) = encryption_key { - encrypt_buffer(page_idx, buffer, key) + if let Some(ctx) = encryption_ctx { + encrypt_buffer(page_idx, buffer, ctx) } else { buffer } @@ -135,7 +135,7 @@ impl DatabaseStorage for DatabaseFile { first_page_idx: usize, page_size: usize, buffers: Vec>, - encryption_key: Option<&EncryptionKey>, + encryption_key: Option<&PerConnEncryptionContext>, c: Completion, ) -> Result { assert!(first_page_idx > 0); @@ -145,11 +145,11 @@ impl DatabaseStorage for DatabaseFile { let pos = (first_page_idx - 1) * page_size; let buffers = { - if let Some(key) = encryption_key { + if let Some(ctx) = encryption_key { buffers .into_iter() .enumerate() - .map(|(i, buffer)| encrypt_buffer(first_page_idx + i, buffer, key)) + .map(|(i, buffer)| encrypt_buffer(first_page_idx + i, buffer, ctx)) .collect::>() } else { buffers @@ -184,7 +184,11 @@ impl DatabaseFile { } } -fn encrypt_buffer(page_idx: usize, buffer: Arc, key: &EncryptionKey) -> Arc { - let encrypted_data = encrypt_page(buffer.as_slice(), page_idx, key).unwrap(); +fn encrypt_buffer( + page_idx: usize, + buffer: Arc, + ctx: &PerConnEncryptionContext, +) -> Arc { + let encrypted_data = ctx.encrypt_page(buffer.as_slice(), page_idx).unwrap(); Arc::new(Buffer::new(encrypted_data.to_vec())) } diff --git a/core/storage/pager.rs b/core/storage/pager.rs index c1247449c..e18f95a94 100644 --- a/core/storage/pager.rs +++ b/core/storage/pager.rs @@ -28,7 +28,9 @@ use super::btree::{btree_init_page, BTreePage}; use super::page_cache::{CacheError, CacheResizeResult, DumbLruPageCache, PageCacheKey}; use super::sqlite3_ondisk::begin_write_btree_page; use super::wal::CheckpointMode; -use crate::storage::encryption::{EncryptionKey, ENCRYPTION_METADATA_SIZE}; +use crate::storage::encryption::{ + EncryptionKey, PerConnEncryptionContext, ENCRYPTION_METADATA_SIZE, +}; /// SQLite's default maximum page count const DEFAULT_MAX_PAGE_COUNT: u32 = 0xfffffffe; @@ -483,7 +485,7 @@ pub struct Pager { header_ref_state: RefCell, #[cfg(not(feature = "omit_autovacuum"))] btree_create_vacuum_full_state: Cell, - pub(crate) encryption_key: RefCell>, + pub(crate) encryption_ctx: RefCell>, } #[derive(Debug, Clone)] @@ -585,7 +587,7 @@ impl Pager { header_ref_state: RefCell::new(HeaderRefState::Start), #[cfg(not(feature = "omit_autovacuum"))] btree_create_vacuum_full_state: Cell::new(BtreeCreateVacuumFullState::Start), - encryption_key: RefCell::new(None), + encryption_ctx: RefCell::new(None), }) } @@ -1072,7 +1074,7 @@ impl Pager { page_idx, page.clone(), allow_empty_read, - self.encryption_key.borrow().as_ref(), + self.encryption_ctx.borrow().as_ref(), )?; return Ok((page, c)); }; @@ -1090,7 +1092,7 @@ impl Pager { page_idx, page.clone(), allow_empty_read, - self.encryption_key.borrow().as_ref(), + self.encryption_ctx.borrow().as_ref(), )?; Ok((page, c)) } @@ -1116,7 +1118,7 @@ impl Pager { page_idx: usize, page: PageRef, allow_empty_read: bool, - encryption_key: Option<&EncryptionKey>, + encryption_key: Option<&PerConnEncryptionContext>, ) -> Result { sqlite3_ondisk::begin_read_page( self.db_file.clone(), @@ -1694,7 +1696,7 @@ impl Pager { default_header.database_size = 1.into(); // if a key is set, then we will reserve space for encryption metadata - if self.encryption_key.borrow().is_some() { + if self.encryption_ctx.borrow().is_some() { default_header.reserved_space = ENCRYPTION_METADATA_SIZE as u8; } @@ -2076,10 +2078,11 @@ impl Pager { Ok(IOResult::Done(f(header))) } - pub fn set_encryption_key(&self, key: Option) { - self.encryption_key.replace(key.clone()); + pub fn set_encryption_context(&self, key: &EncryptionKey) { + let encryption_ctx = PerConnEncryptionContext::new(key).unwrap(); + self.encryption_ctx.replace(Some(encryption_ctx.clone())); let Some(wal) = self.wal.as_ref() else { return }; - wal.borrow_mut().set_encryption_key(key) + wal.borrow_mut().set_encryption_context(encryption_ctx) } } diff --git a/core/storage/sqlite3_ondisk.rs b/core/storage/sqlite3_ondisk.rs index 7bc1202c8..d1a37615d 100644 --- a/core/storage/sqlite3_ondisk.rs +++ b/core/storage/sqlite3_ondisk.rs @@ -59,7 +59,7 @@ use crate::storage::btree::offset::{ use crate::storage::btree::{payload_overflow_threshold_max, payload_overflow_threshold_min}; use crate::storage::buffer_pool::BufferPool; use crate::storage::database::DatabaseStorage; -use crate::storage::encryption::EncryptionKey; +use crate::storage::encryption::PerConnEncryptionContext; use crate::storage::pager::Pager; use crate::storage::wal::READMARK_NOT_USED; use crate::types::{RawSlice, RefValue, SerialType, SerialTypeKind, TextRef, TextSubtype}; @@ -870,7 +870,7 @@ pub fn begin_read_page( page: PageRef, page_idx: usize, allow_empty_read: bool, - encryption_key: Option<&EncryptionKey>, + encryption_key: Option<&PerConnEncryptionContext>, ) -> Result { tracing::trace!("begin_read_btree_page(page_idx = {})", page_idx); let buf = buffer_pool.get_page(); @@ -965,7 +965,7 @@ pub fn write_pages_vectored( pager: &Pager, batch: BTreeMap>, done_flag: Arc, - encryption_key: Option<&EncryptionKey>, + encryption_key: Option<&PerConnEncryptionContext>, ) -> Result> { if batch.is_empty() { done_flag.store(true, Ordering::Relaxed); diff --git a/core/storage/wal.rs b/core/storage/wal.rs index 35882bf7d..0a903f4b3 100644 --- a/core/storage/wal.rs +++ b/core/storage/wal.rs @@ -17,7 +17,7 @@ use super::sqlite3_ondisk::{self, checksum_wal, WalHeader, WAL_MAGIC_BE, WAL_MAG use crate::fast_lock::SpinLock; use crate::io::{clock, File, IO}; use crate::result::LimboResult; -use crate::storage::encryption::{decrypt_page, encrypt_page, EncryptionKey}; +use crate::storage::encryption::PerConnEncryptionContext; use crate::storage::sqlite3_ondisk::{ begin_read_wal_frame, begin_read_wal_frame_raw, finish_read_page, prepare_wal_frame, write_pages_vectored, PageSize, WAL_FRAME_HEADER_SIZE, WAL_HEADER_SIZE, @@ -297,7 +297,7 @@ pub trait Wal: Debug { /// Return unique set of pages changed **after** frame_watermark position and until current WAL session max_frame_no fn changed_pages_after(&self, frame_watermark: u64) -> Result>; - fn set_encryption_key(&mut self, key: Option); + fn set_encryption_context(&mut self, ctx: PerConnEncryptionContext); #[cfg(debug_assertions)] fn as_any(&self) -> &dyn std::any::Any; @@ -568,7 +568,7 @@ pub struct WalFile { /// Manages locks needed for checkpointing checkpoint_guard: Option, - encryption_key: RefCell>, + encryption_ctx: RefCell>, } impl fmt::Debug for WalFile { @@ -1034,7 +1034,7 @@ impl Wal for WalFile { page.set_locked(); let frame = page.clone(); let page_idx = page.get().id; - let key = self.encryption_key.borrow().clone(); + let encryption_ctx = self.encryption_ctx.borrow().clone(); let seq = self.header.checkpoint_seq; let complete = Box::new(move |res: Result<(Arc, i32), CompletionError>| { let Ok((buf, bytes_read)) = res else { @@ -1047,8 +1047,8 @@ impl Wal for WalFile { "read({bytes_read}) less than expected({buf_len}): frame_id={frame_id}" ); let cloned = frame.clone(); - if let Some(key) = key.clone() { - match decrypt_page(buf.as_slice(), page_idx, &key) { + if let Some(ctx) = encryption_ctx.clone() { + match ctx.decrypt_page(buf.as_slice(), page_idx) { Ok(decrypted_data) => { buf.as_mut_slice().copy_from_slice(&decrypted_data); } @@ -1213,15 +1213,15 @@ impl Wal for WalFile { let page_content = page.get_contents(); let page_buf = page_content.as_ptr(); - let key = self.encryption_key.borrow(); + let encryption_ctx = self.encryption_ctx.borrow(); let encrypted_data = { - if let Some(key) = key.as_ref() { - Some(encrypt_page(page_buf, page_id, key)?) + if let Some(key) = encryption_ctx.as_ref() { + Some(key.encrypt_page(page_buf, page_id)?) } else { None } }; - let data_to_write = if key.as_ref().is_some() { + let data_to_write = if encryption_ctx.as_ref().is_some() { encrypted_data.as_ref().unwrap().as_slice() } else { page_buf @@ -1374,8 +1374,8 @@ impl Wal for WalFile { self } - fn set_encryption_key(&mut self, key: Option) { - self.encryption_key.replace(key); + fn set_encryption_context(&mut self, ctx: PerConnEncryptionContext) { + self.encryption_ctx.replace(Some(ctx)); } } @@ -1413,7 +1413,7 @@ impl WalFile { prev_checkpoint: CheckpointResult::default(), checkpoint_guard: None, header: *header, - encryption_key: RefCell::new(None), + encryption_ctx: RefCell::new(None), } } @@ -1665,7 +1665,7 @@ impl WalFile { pager, batch_map, done_flag, - self.encryption_key.borrow().as_ref(), + self.encryption_ctx.borrow().as_ref(), )?); } } diff --git a/core/translate/pragma.rs b/core/translate/pragma.rs index 1cb9a9d04..c7528027a 100644 --- a/core/translate/pragma.rs +++ b/core/translate/pragma.rs @@ -312,7 +312,7 @@ fn update_pragma( PragmaName::EncryptionKey => { let value = parse_string(&value)?; let key = EncryptionKey::from_string(&value); - connection.set_encryption_key(Some(key)); + connection.set_encryption_key(key); Ok((program, TransactionMode::None)) } } From fd63688edea580017f99ddec67d225a0ca712ed0 Mon Sep 17 00:00:00 2001 From: TcMits Date: Sat, 23 Aug 2025 15:07:32 +0700 Subject: [PATCH 03/12] reduce cloning Token in parser --- parser/src/parser.rs | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/parser/src/parser.rs b/parser/src/parser.rs index 8d1f1e442..124058fc9 100644 --- a/parser/src/parser.rs +++ b/parser/src/parser.rs @@ -31,7 +31,7 @@ macro_rules! peek_expect { expected: &[ $($x,)* ], - got: token.token_type.unwrap(), + got: tt, }) } } @@ -223,10 +223,11 @@ impl<'a> Parser<'a> { } Some(token) => { if !found_semi { + let tt = token.token_type.unwrap(); return Err(Error::ParseUnexpectedToken { parsed_offset: (self.offset(), 1).into(), expected: &[TK_SEMI], - got: token.token_type.unwrap(), + got: tt, }); } @@ -253,7 +254,7 @@ impl<'a> Parser<'a> { } } - fn next_token(&mut self) -> Result>> { + fn next_token(&mut self) -> Result>> { debug_assert!(!self.peekable); let mut next = self.consume_lexer_without_whitespaces_or_comments(); @@ -479,9 +480,9 @@ impl<'a> Parser<'a> { match next { None => Ok(None), // EOF Some(Ok(tok)) => { - self.current_token = tok.clone(); + self.current_token = tok; self.peekable = true; - Ok(Some(tok)) + Ok(Some(&self.current_token)) } Some(Err(err)) => Err(err), } @@ -520,16 +521,21 @@ impl<'a> Parser<'a> { /// Get the next token from the lexer #[inline] fn eat(&mut self) -> Result>> { - let result = self.peek()?; - self.peekable = false; // Clear the peek mark after consuming - Ok(result) + match self.peek()? { + None => Ok(None), + Some(tok) => { + let result = tok.clone(); + self.peekable = false; // Clear the peek mark after consuming + Ok(Some(result)) + } + } } /// Peek at the next token without consuming it #[inline] - fn peek(&mut self) -> Result>> { + fn peek(&mut self) -> Result>> { if self.peekable { - return Ok(Some(self.current_token.clone())); + return Ok(Some(&self.current_token)); } self.next_token() @@ -544,7 +550,7 @@ impl<'a> Parser<'a> { } #[inline] - fn peek_no_eof(&mut self) -> Result> { + fn peek_no_eof(&mut self) -> Result<&Token<'a>> { match self.peek()? { None => Err(Error::ParseUnexpectedEOF), Some(token) => Ok(token), @@ -966,7 +972,7 @@ impl<'a> Parser<'a> { let mut type_name = if let Some(tok) = self.peek()? { match tok.token_type.unwrap().fallback_id_if_ok() { TK_ID | TK_STRING => { - eat_assert!(self, TK_ID, TK_STRING); + let tok = eat_assert!(self, TK_ID, TK_STRING); from_bytes(tok.value) } _ => return Ok(None), @@ -978,7 +984,7 @@ impl<'a> Parser<'a> { while let Some(tok) = self.peek()? { match tok.token_type.unwrap().fallback_id_if_ok() { TK_ID | TK_STRING => { - eat_assert!(self, TK_ID, TK_STRING); + let tok = eat_assert!(self, TK_ID, TK_STRING); type_name.push(' '); type_name.push_str(from_bytes_as_str(tok.value)); } @@ -1324,25 +1330,25 @@ impl<'a> Parser<'a> { Ok(Box::new(Expr::Literal(Literal::Null))) } TK_BLOB => { - eat_assert!(self, TK_BLOB); + let tok = eat_assert!(self, TK_BLOB); Ok(Box::new(Expr::Literal(Literal::Blob(from_bytes( tok.value, ))))) } TK_FLOAT => { - eat_assert!(self, TK_FLOAT); + let tok = eat_assert!(self, TK_FLOAT); Ok(Box::new(Expr::Literal(Literal::Numeric(from_bytes( tok.value, ))))) } TK_INTEGER => { - eat_assert!(self, TK_INTEGER); + let tok = eat_assert!(self, TK_INTEGER); Ok(Box::new(Expr::Literal(Literal::Numeric(from_bytes( tok.value, ))))) } TK_VARIABLE => { - eat_assert!(self, TK_VARIABLE); + let tok = eat_assert!(self, TK_VARIABLE); Ok(Box::new(Expr::Variable(from_bytes(tok.value)))) } TK_CAST => { From 80eca66be9fe5dc77b07c99b34dd026b5099d9be Mon Sep 17 00:00:00 2001 From: themixednuts Date: Sat, 23 Aug 2025 03:08:15 -0500 Subject: [PATCH 04/12] fix: normalize quotes in update fixes: #2744 --- core/schema.rs | 3 ++- core/translate/update.rs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/schema.rs b/core/schema.rs index e99aee62b..ba6a9ee7c 100644 --- a/core/schema.rs +++ b/core/schema.rs @@ -255,7 +255,8 @@ impl Schema { } pub fn table_has_indexes(&self, table_name: &str) -> bool { - self.has_indexes.contains(table_name) + let name = normalize_ident(table_name); + self.has_indexes.contains(&name) } pub fn table_set_has_index(&mut self, table_name: &str) { diff --git a/core/translate/update.rs b/core/translate/update.rs index 4e234af88..f94ebe118 100644 --- a/core/translate/update.rs +++ b/core/translate/update.rs @@ -132,6 +132,7 @@ pub fn prepare_update_plan( Some(table) => table, None => bail_parse_error!("Parse error: no such table: {}", table_name), }; + let table_name = table.get_name(); let iter_dir = body .order_by .first() @@ -149,7 +150,7 @@ pub fn prepare_update_plan( Table::BTree(btree_table) => Table::BTree(btree_table.clone()), _ => unreachable!(), }, - identifier: table_name.as_str().to_string(), + identifier: table_name.to_string(), internal_id: program.table_reference_counter.next(), op: build_scan_op(&table, iter_dir), join_info: None, @@ -235,7 +236,7 @@ pub fn prepare_update_plan( Table::BTree(btree_table) => Table::BTree(btree_table.clone()), _ => unreachable!(), }, - identifier: table_name.as_str().to_string(), + identifier: table_name.to_string(), internal_id, op: build_scan_op(&table, iter_dir), join_info: None, @@ -334,7 +335,7 @@ pub fn prepare_update_plan( // Check what indexes will need to be updated by checking set_clauses and see // if a column is contained in an index. - let indexes = schema.get_indices(table_name.as_str()); + let indexes = schema.get_indices(table_name); let rowid_alias_used = set_clauses .iter() .any(|(idx, _)| columns[*idx].is_rowid_alias); From 9a418f1d3efe65ef7903403bdf5d53508d397042 Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Sat, 23 Aug 2025 15:55:01 -0400 Subject: [PATCH 05/12] Replace a couple refcells with cell in pager --- core/storage/pager.rs | 44 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/core/storage/pager.rs b/core/storage/pager.rs index c718485c1..847709592 100644 --- a/core/storage/pager.rs +++ b/core/storage/pager.rs @@ -347,7 +347,7 @@ pub enum BtreePageAllocMode { /// This will keep track of the state of current cache commit in order to not repeat work struct CommitInfo { - state: CommitState, + state: Cell, } /// Track the state of the auto-vacuum mode. @@ -460,10 +460,10 @@ pub struct Pager { pub io: Arc, dirty_pages: Rc>>>, - commit_info: RefCell, + commit_info: CommitInfo, checkpoint_state: RefCell, syncing: Rc>, - auto_vacuum_mode: RefCell, + auto_vacuum_mode: Cell, /// 0 -> Database is empty, /// 1 -> Database is being initialized, /// 2 -> Database is initialized and ready for use. @@ -571,13 +571,13 @@ impl Pager { dirty_pages: Rc::new(RefCell::new(HashSet::with_hasher( hash::BuildHasherDefault::new(), ))), - commit_info: RefCell::new(CommitInfo { - state: CommitState::Start, - }), + commit_info: CommitInfo { + state: CommitState::Start.into(), + }, syncing: Rc::new(Cell::new(false)), checkpoint_state: RefCell::new(CheckpointState::Checkpoint), buffer_pool, - auto_vacuum_mode: RefCell::new(AutoVacuumMode::None), + auto_vacuum_mode: Cell::new(AutoVacuumMode::None), db_state, init_lock, allocate_page1_state, @@ -620,11 +620,11 @@ impl Pager { } pub fn get_auto_vacuum_mode(&self) -> AutoVacuumMode { - *self.auto_vacuum_mode.borrow() + self.auto_vacuum_mode.get() } pub fn set_auto_vacuum_mode(&self, mode: AutoVacuumMode) { - *self.auto_vacuum_mode.borrow_mut() = mode; + self.auto_vacuum_mode.set(mode); } /// Retrieves the pointer map entry for a given database page. @@ -840,8 +840,8 @@ impl Pager { // If autovacuum is enabled, we need to allocate a new page number that is greater than the largest root page number #[cfg(not(feature = "omit_autovacuum"))] { - let auto_vacuum_mode = self.auto_vacuum_mode.borrow(); - match *auto_vacuum_mode { + let auto_vacuum_mode = self.auto_vacuum_mode.get(); + match auto_vacuum_mode { AutoVacuumMode::None => { let page = return_if_io!(self.do_allocate_page(page_type, 0, BtreePageAllocMode::Any)); @@ -1299,7 +1299,7 @@ impl Pager { }; let mut checkpoint_result = CheckpointResult::default(); let res = loop { - let state = self.commit_info.borrow().state; + let state = self.commit_info.state.get(); trace!(?state); match state { CommitState::Start => { @@ -1354,35 +1354,35 @@ impl Pager { if completions.is_empty() { return Ok(IOResult::Done(PagerCommitResult::WalWritten)); } else { - self.commit_info.borrow_mut().state = CommitState::SyncWal; + self.commit_info.state.set(CommitState::SyncWal); io_yield_many!(completions); } } CommitState::SyncWal => { - self.commit_info.borrow_mut().state = CommitState::AfterSyncWal; + self.commit_info.state.set(CommitState::AfterSyncWal); let c = wal.borrow_mut().sync()?; io_yield_one!(c); } CommitState::AfterSyncWal => { turso_assert!(!wal.borrow().is_syncing(), "wal should have synced"); if wal_auto_checkpoint_disabled || !wal.borrow().should_checkpoint() { - self.commit_info.borrow_mut().state = CommitState::Start; + self.commit_info.state.set(CommitState::Start); break PagerCommitResult::WalWritten; } - self.commit_info.borrow_mut().state = CommitState::Checkpoint; + self.commit_info.state.set(CommitState::Checkpoint); } CommitState::Checkpoint => { checkpoint_result = return_if_io!(self.checkpoint()); - self.commit_info.borrow_mut().state = CommitState::SyncDbFile; + self.commit_info.state.set(CommitState::SyncDbFile); } CommitState::SyncDbFile => { let c = sqlite3_ondisk::begin_sync(self.db_file.clone(), self.syncing.clone())?; - self.commit_info.borrow_mut().state = CommitState::AfterSyncDbFile; + self.commit_info.state.set(CommitState::AfterSyncDbFile); io_yield_one!(c); } CommitState::AfterSyncDbFile => { turso_assert!(!self.syncing.get(), "should have finished syncing"); - self.commit_info.borrow_mut().state = CommitState::Start; + self.commit_info.state.set(CommitState::Start); break PagerCommitResult::Checkpointed(checkpoint_result); } } @@ -1817,7 +1817,7 @@ impl Pager { // If the following conditions are met, allocate a pointer map page, add to cache and increment the database size // - autovacuum is enabled // - the last page is a pointer map page - if matches!(*self.auto_vacuum_mode.borrow(), AutoVacuumMode::Full) + if matches!(self.auto_vacuum_mode.get(), AutoVacuumMode::Full) && is_ptrmap_page(new_db_size + 1, header.page_size.get() as usize) { // we will allocate a ptrmap page, so increment size @@ -2083,9 +2083,7 @@ impl Pager { fn reset_internal_states(&self) { self.checkpoint_state.replace(CheckpointState::Checkpoint); self.syncing.replace(false); - self.commit_info.replace(CommitInfo { - state: CommitState::Start, - }); + self.commit_info.state.set(CommitState::Start); self.allocate_page_state.replace(AllocatePageState::Start); self.free_page_state.replace(FreePageState::Start); #[cfg(not(feature = "omit_autovacuum"))] From df41994eccaa4c525506e6b3bb2ee7b19f9fd474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EC=9A=B0?= Date: Sun, 24 Aug 2025 09:15:07 +0900 Subject: [PATCH 06/12] Implement execute batch --- .../java/tech/turso/jdbc4/JDBC4Statement.java | 79 ++++++- .../tech/turso/jdbc4/JDBC4StatementTest.java | 215 ++++++++++++++++++ 2 files changed, 290 insertions(+), 4 deletions(-) diff --git a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java index b86b838f5..67225aac5 100644 --- a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java +++ b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java @@ -2,11 +2,16 @@ package tech.turso.jdbc4; import static java.util.Objects.requireNonNull; +import java.sql.BatchUpdateException; import java.sql.Connection; +import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLTimeoutException; import java.sql.SQLWarning; import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.locks.ReentrantLock; import tech.turso.annotations.Nullable; import tech.turso.annotations.SkipNullableCheck; @@ -33,6 +38,12 @@ public class JDBC4Statement implements Statement { private ReentrantLock connectionLock = new ReentrantLock(); + /** + * List of SQL statements to be executed as a batch. Used for batch processing as per JDBC + * specification. + */ + private List batchCommands = new ArrayList<>(); + public JDBC4Statement(JDBC4Connection connection) { this( connection, @@ -232,18 +243,78 @@ public class JDBC4Statement implements Statement { @Override public void addBatch(String sql) throws SQLException { - // TODO + ensureOpen(); + if (sql == null) { + throw new SQLException("SQL command cannot be null"); + } + batchCommands.add(sql); } @Override public void clearBatch() throws SQLException { - // TODO + ensureOpen(); + batchCommands.clear(); } @Override public int[] executeBatch() throws SQLException { - // TODO - return new int[0]; + ensureOpen(); + + int[] updateCounts = new int[batchCommands.size()]; + List failedCommands = new ArrayList<>(); + int[] successfulCounts = new int[batchCommands.size()]; + + // Execute each command in the batch + for (int i = 0; i < batchCommands.size(); i++) { + String sql = batchCommands.get(i); + try { + // Check if the statement returns a ResultSet (SELECT statements) + // In batch processing, SELECT statements should throw an exception + if (execute(sql)) { + // This means the statement returned a ResultSet, which is not allowed in batch + failedCommands.add(sql); + updateCounts[i] = EXECUTE_FAILED; + // Create a BatchUpdateException for the failed command + BatchUpdateException bue = + new BatchUpdateException( + "Batch entry " + + i + + " (" + + sql + + ") was aborted. " + + "Batch commands cannot return result sets.", + "HY000", // General error SQL state + 0, + updateCounts); + // Clear the batch after failure + clearBatch(); + throw bue; + } else { + // For DML statements, get the update count + updateCounts[i] = getUpdateCount(); + } + } catch (SQLException e) { + // Handle SQL exceptions during batch execution + failedCommands.add(sql); + updateCounts[i] = EXECUTE_FAILED; + + // Create a BatchUpdateException with the partial results + BatchUpdateException bue = + new BatchUpdateException( + "Batch entry " + i + " (" + sql + ") failed: " + e.getMessage(), + e.getSQLState(), + e.getErrorCode(), + updateCounts, + e.getCause()); + // Clear the batch after failure + clearBatch(); + throw bue; + } + } + + // Clear the batch after successful execution + clearBatch(); + return updateCounts; } @Override diff --git a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java index e8266c76a..53572d3be 100644 --- a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java +++ b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.sql.BatchUpdateException; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -120,4 +121,218 @@ class JDBC4StatementTest { assertThat(stmt.executeUpdate("DELETE FROM s1")).isEqualTo(3); } + + /** Tests for batch processing functionality */ + @Test + void testAddBatch_single_statement() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Add a single batch command + stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); + + // Execute batch + int[] updateCounts = stmt.executeBatch(); + + // Verify results + assertThat(updateCounts).hasSize(1); + assertThat(updateCounts[0]).isEqualTo(1); + + // Verify data was inserted + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); + assertTrue(rs.next()); + assertThat(rs.getInt(1)).isEqualTo(1); + } + + @Test + void testAddBatch_multiple_statements() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Add multiple batch commands + stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); + stmt.addBatch("INSERT INTO batch_test VALUES (2, 'test2');"); + stmt.addBatch("INSERT INTO batch_test VALUES (3, 'test3');"); + + // Execute batch + int[] updateCounts = stmt.executeBatch(); + + // Verify results + assertThat(updateCounts).hasSize(3); + assertThat(updateCounts[0]).isEqualTo(1); + assertThat(updateCounts[1]).isEqualTo(1); + assertThat(updateCounts[2]).isEqualTo(1); + + // Verify all data was inserted + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); + assertTrue(rs.next()); + assertThat(rs.getInt(1)).isEqualTo(3); + } + + @Test + void testAddBatch_with_updates_and_deletes() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Insert initial data + stmt.execute( + "INSERT INTO batch_test VALUES (1, 'initial1'), (2, 'initial2'), (3, 'initial3');"); + + // Add batch commands with different operations + stmt.addBatch("UPDATE batch_test SET value = 'updated' WHERE id = 1;"); + stmt.addBatch("DELETE FROM batch_test WHERE id = 2;"); + stmt.addBatch("INSERT INTO batch_test VALUES (4, 'new');"); + + // Execute batch + int[] updateCounts = stmt.executeBatch(); + + // Verify update counts + assertThat(updateCounts).hasSize(3); + assertThat(updateCounts[0]).isEqualTo(1); // UPDATE affected 1 row + assertThat(updateCounts[1]).isEqualTo(1); // DELETE affected 1 row + assertThat(updateCounts[2]).isEqualTo(1); // INSERT affected 1 row + + // Verify final state + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); + assertTrue(rs.next()); + assertThat(rs.getInt(1)).isEqualTo(3); // 3 initial - 1 deleted + 1 inserted = 3 + } + + @Test + void testClearBatch() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Add batch commands + stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); + stmt.addBatch("INSERT INTO batch_test VALUES (2, 'test2');"); + + // Clear the batch + stmt.clearBatch(); + + // Execute batch should return empty array + int[] updateCounts = stmt.executeBatch(); + assertThat(updateCounts).isEmpty(); + + // Verify no data was inserted + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); + assertTrue(rs.next()); + assertThat(rs.getInt(1)).isEqualTo(0); + } + + @Test + void testBatch_with_DDL_statements() throws SQLException { + // DDL statements should work in batch + stmt.addBatch("CREATE TABLE batch_test1 (id INTEGER);"); + stmt.addBatch("CREATE TABLE batch_test2 (id INTEGER);"); + stmt.addBatch("CREATE TABLE batch_test3 (id INTEGER);"); + + // Execute batch + int[] updateCounts = stmt.executeBatch(); + + // DDL statements typically return 0 for update count + assertThat(updateCounts).hasSize(3); + assertThat(updateCounts[0]).isEqualTo(0); + assertThat(updateCounts[1]).isEqualTo(0); + assertThat(updateCounts[2]).isEqualTo(0); + + // Verify tables were created by inserting data + assertDoesNotThrow(() -> stmt.execute("INSERT INTO batch_test1 VALUES (1);")); + assertDoesNotThrow(() -> stmt.execute("INSERT INTO batch_test2 VALUES (1);")); + assertDoesNotThrow(() -> stmt.execute("INSERT INTO batch_test3 VALUES (1);")); + } + + @Test + void testBatch_with_SELECT_should_throw_exception() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + stmt.execute("INSERT INTO batch_test VALUES (1, 'test1');"); + + // Add a SELECT statement to batch (not allowed) + stmt.addBatch("INSERT INTO batch_test VALUES (2, 'test2');"); + stmt.addBatch("SELECT * FROM batch_test;"); // This should cause an exception + stmt.addBatch("INSERT INTO batch_test VALUES (3, 'test3');"); + + // Execute batch should throw BatchUpdateException + BatchUpdateException exception = + assertThrows(BatchUpdateException.class, () -> stmt.executeBatch()); + + // Verify exception message + assertTrue(exception.getMessage().contains("Batch commands cannot return result sets")); + + // Verify update counts for executed statements before the failure + int[] updateCounts = exception.getUpdateCounts(); + assertThat(updateCounts).hasSize(3); + assertThat(updateCounts[0]).isEqualTo(1); // First INSERT succeeded + assertThat(updateCounts[1]).isEqualTo(Statement.EXECUTE_FAILED); // SELECT failed + // The third statement may not have been executed depending on implementation + } + + @Test + void testBatch_with_null_command_should_throw_exception() throws SQLException { + // Adding null command should throw SQLException + assertThrows(SQLException.class, () -> stmt.addBatch(null)); + } + + @Test + void testBatch_operations_on_closed_statement_should_throw_exception() throws SQLException { + stmt.close(); + + // All batch operations should throw SQLException on closed statement + assertThrows(SQLException.class, () -> stmt.addBatch("INSERT INTO test VALUES (1);")); + assertThrows(SQLException.class, () -> stmt.clearBatch()); + assertThrows(SQLException.class, () -> stmt.executeBatch()); + } + + @Test + void testBatch_with_syntax_error_should_throw_exception() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Add batch commands with a syntax error + stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); + stmt.addBatch("INVALID SQL SYNTAX;"); // This should cause an exception + stmt.addBatch("INSERT INTO batch_test VALUES (3, 'test3');"); + + // Execute batch should throw BatchUpdateException + BatchUpdateException exception = + assertThrows(BatchUpdateException.class, () -> stmt.executeBatch()); + + // Verify update counts show partial execution + int[] updateCounts = exception.getUpdateCounts(); + assertThat(updateCounts).hasSize(3); + assertThat(updateCounts[0]).isEqualTo(1); // First INSERT succeeded + assertThat(updateCounts[1]).isEqualTo(Statement.EXECUTE_FAILED); // Invalid SQL failed + } + + @Test + void testBatch_empty_batch_returns_empty_array() throws SQLException { + // Execute empty batch + int[] updateCounts = stmt.executeBatch(); + + // Should return empty array + assertThat(updateCounts).isEmpty(); + } + + @Test + void testBatch_clears_after_successful_execution() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Add and execute batch + stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); + stmt.executeBatch(); + + // Execute batch again should return empty array (batch was cleared) + int[] updateCounts = stmt.executeBatch(); + assertThat(updateCounts).isEmpty(); + } + + @Test + void testBatch_clears_after_failed_execution() throws SQLException { + stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); + + // Add batch with SELECT statement that will fail + stmt.addBatch("SELECT * FROM batch_test;"); + + // Execute batch should fail + assertThrows(BatchUpdateException.class, () -> stmt.executeBatch()); + + // Execute batch again should return empty array (batch was cleared after failure) + int[] updateCounts = stmt.executeBatch(); + assertThat(updateCounts).isEmpty(); + } } From 346525e5f0c8877dd5d42438b23a7936f18de013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EC=9A=B0?= Date: Sun, 24 Aug 2025 09:25:59 +0900 Subject: [PATCH 07/12] Update test --- .../java/tech/turso/jdbc4/JDBC4Statement.java | 44 ++++++++++++++----- .../tech/turso/jdbc4/JDBC4StatementTest.java | 4 +- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java index 67225aac5..2230b1f96 100644 --- a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java +++ b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java @@ -4,15 +4,14 @@ import static java.util.Objects.requireNonNull; import java.sql.BatchUpdateException; import java.sql.Connection; -import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.SQLTimeoutException; import java.sql.SQLWarning; import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.concurrent.locks.ReentrantLock; + import tech.turso.annotations.Nullable; import tech.turso.annotations.SkipNullableCheck; import tech.turso.core.TursoResultSet; @@ -268,13 +267,9 @@ public class JDBC4Statement implements Statement { for (int i = 0; i < batchCommands.size(); i++) { String sql = batchCommands.get(i); try { - // Check if the statement returns a ResultSet (SELECT statements) - // In batch processing, SELECT statements should throw an exception - if (execute(sql)) { - // This means the statement returned a ResultSet, which is not allowed in batch + if (!isBatchCompatibleStatement(sql)) { failedCommands.add(sql); updateCounts[i] = EXECUTE_FAILED; - // Create a BatchUpdateException for the failed command BatchUpdateException bue = new BatchUpdateException( "Batch entry " @@ -289,12 +284,12 @@ public class JDBC4Statement implements Statement { // Clear the batch after failure clearBatch(); throw bue; - } else { - // For DML statements, get the update count - updateCounts[i] = getUpdateCount(); } + + execute(sql); + // For DML statements, get the update count + updateCounts[i] = getUpdateCount(); } catch (SQLException e) { - // Handle SQL exceptions during batch execution failedCommands.add(sql); updateCounts[i] = EXECUTE_FAILED; @@ -317,6 +312,33 @@ public class JDBC4Statement implements Statement { return updateCounts; } + /** + * Checks if a SQL statement is compatible with batch execution. Only INSERT, UPDATE, DELETE, and + * DDL statements are allowed in batch. SELECT and other query statements are not allowed. + * + * @param sql The SQL statement to check + * @return true if the statement is batch-compatible, false otherwise + */ + private boolean isBatchCompatibleStatement(String sql) { + if (sql == null || sql.trim().isEmpty()) { + return false; + } + + // Trim and convert to uppercase for case-insensitive comparison + String trimmedSql = sql.trim().toUpperCase(); + + // Check if it starts with batch-compatible keywords + return trimmedSql.startsWith("INSERT") + || trimmedSql.startsWith("UPDATE") + || trimmedSql.startsWith("DELETE") + || trimmedSql.startsWith("CREATE") + || trimmedSql.startsWith("DROP") + || trimmedSql.startsWith("ALTER") + || trimmedSql.startsWith("TRUNCATE") + || trimmedSql.startsWith("REPLACE") + || trimmedSql.startsWith("MERGE"); + } + @Override public Connection getConnection() { return connection; diff --git a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java index 53572d3be..4fe1d32ed 100644 --- a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java +++ b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java @@ -176,7 +176,7 @@ class JDBC4StatementTest { "INSERT INTO batch_test VALUES (1, 'initial1'), (2, 'initial2'), (3, 'initial3');"); // Add batch commands with different operations - stmt.addBatch("UPDATE batch_test SET value = 'updated' WHERE id = 1;"); + stmt.addBatch("UPDATE batch_test SET value = 'updated';"); stmt.addBatch("DELETE FROM batch_test WHERE id = 2;"); stmt.addBatch("INSERT INTO batch_test VALUES (4, 'new');"); @@ -185,7 +185,7 @@ class JDBC4StatementTest { // Verify update counts assertThat(updateCounts).hasSize(3); - assertThat(updateCounts[0]).isEqualTo(1); // UPDATE affected 1 row + assertThat(updateCounts[0]).isEqualTo(3); // UPDATE affected 3 row assertThat(updateCounts[1]).isEqualTo(1); // DELETE affected 1 row assertThat(updateCounts[2]).isEqualTo(1); // INSERT affected 1 row From bf1473dc08aa72dbe008cac1ac209edd9742075a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EC=9A=B0?= Date: Sun, 24 Aug 2025 09:35:29 +0900 Subject: [PATCH 08/12] Override JDBC4PreparedStatement to throw exception when calling addBatch method --- .../main/java/tech/turso/jdbc4/JDBC4PreparedStatement.java | 5 +++++ .../java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java | 2 -- bindings/java/src/test/resources/turso/.rustc_info.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4PreparedStatement.java b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4PreparedStatement.java index a3f8b3d4d..60b5316fe 100644 --- a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4PreparedStatement.java +++ b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4PreparedStatement.java @@ -176,6 +176,11 @@ public final class JDBC4PreparedStatement extends JDBC4Statement implements Prep // TODO } + @Override + public void addBatch(String sql) throws SQLException { + throw new SQLException("addBatch(String) cannot be called on a PreparedStatement"); + } + @Override public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException {} diff --git a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java index 2230b1f96..3e722f8cf 100644 --- a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java +++ b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java @@ -11,7 +11,6 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.concurrent.locks.ReentrantLock; - import tech.turso.annotations.Nullable; import tech.turso.annotations.SkipNullableCheck; import tech.turso.core.TursoResultSet; @@ -261,7 +260,6 @@ public class JDBC4Statement implements Statement { int[] updateCounts = new int[batchCommands.size()]; List failedCommands = new ArrayList<>(); - int[] successfulCounts = new int[batchCommands.size()]; // Execute each command in the batch for (int i = 0; i < batchCommands.size(); i++) { diff --git a/bindings/java/src/test/resources/turso/.rustc_info.json b/bindings/java/src/test/resources/turso/.rustc_info.json index b01291daa..68aeec704 100644 --- a/bindings/java/src/test/resources/turso/.rustc_info.json +++ b/bindings/java/src/test/resources/turso/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":11551670960185020797,"outputs":{"14427667104029986310":{"success":true,"status":"","code":0,"stdout":"rustc 1.83.0 (90b35a623 2024-11-26)\nbinary: rustc\ncommit-hash: 90b35a6239c3d8bdabc530a6a0816f7ff89a0aaf\ncommit-date: 2024-11-26\nhost: x86_64-unknown-linux-gnu\nrelease: 1.83.0\nLLVM version: 19.1.1\n","stderr":""},"11399821309745579047":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/merlin/.rustup/toolchains/1.83.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":4908805493570777128,"outputs":{"11769396151817893336":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/seonwoo960000/.rustup/toolchains/1.88.0-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\ntokio_unstable\nunix\n","stderr":""},"10375286590057847751":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: aarch64-apple-darwin\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""},"10459017571985955891":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/seonwoo960000/.rustup/toolchains/1.88.0-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\ntokio_unstable\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file From 9f6eb8bc923619ddf3f982e6cf61d82434a9d8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EC=9A=B0?= Date: Sun, 24 Aug 2025 10:13:04 +0900 Subject: [PATCH 09/12] Update verification of batch compatible statements using regex --- .../java/tech/turso/jdbc4/JDBC4Statement.java | 41 +++-- .../tech/turso/jdbc4/JDBC4StatementTest.java | 159 +++++++++++++++--- 2 files changed, 157 insertions(+), 43 deletions(-) diff --git a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java index 3e722f8cf..97bf65af9 100644 --- a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java +++ b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java @@ -11,6 +11,7 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; import tech.turso.annotations.Nullable; import tech.turso.annotations.SkipNullableCheck; import tech.turso.core.TursoResultSet; @@ -18,6 +19,20 @@ import tech.turso.core.TursoStatement; public class JDBC4Statement implements Statement { + private static final Pattern BATCH_COMPATIBLE_PATTERN = + Pattern.compile( + "^\\s*" + + // Leading whitespace + "(?:/\\*.*?\\*/\\s*)*" + + // Optional C-style comments + "(?:--[^\\n]*\\n\\s*)*" + + // Optional SQL line comments + "(?:" + + // Start of keywords group + "INSERT|UPDATE|DELETE" + + ")\\b", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private final JDBC4Connection connection; @Nullable protected TursoStatement statement = null; @@ -254,6 +269,7 @@ public class JDBC4Statement implements Statement { batchCommands.clear(); } + // TODO: let's make this batch operation atomic @Override public int[] executeBatch() throws SQLException { ensureOpen(); @@ -310,31 +326,14 @@ public class JDBC4Statement implements Statement { return updateCounts; } - /** - * Checks if a SQL statement is compatible with batch execution. Only INSERT, UPDATE, DELETE, and - * DDL statements are allowed in batch. SELECT and other query statements are not allowed. - * - * @param sql The SQL statement to check - * @return true if the statement is batch-compatible, false otherwise - */ - private boolean isBatchCompatibleStatement(String sql) { + boolean isBatchCompatibleStatement(String sql) { if (sql == null || sql.trim().isEmpty()) { return false; } - // Trim and convert to uppercase for case-insensitive comparison - String trimmedSql = sql.trim().toUpperCase(); - - // Check if it starts with batch-compatible keywords - return trimmedSql.startsWith("INSERT") - || trimmedSql.startsWith("UPDATE") - || trimmedSql.startsWith("DELETE") - || trimmedSql.startsWith("CREATE") - || trimmedSql.startsWith("DROP") - || trimmedSql.startsWith("ALTER") - || trimmedSql.startsWith("TRUNCATE") - || trimmedSql.startsWith("REPLACE") - || trimmedSql.startsWith("MERGE"); + // Check if the SQL matches batch-compatible patterns (DML and DDL) + // This will return false for SELECT, EXPLAIN, PRAGMA, SHOW, etc. + return BATCH_COMPATIBLE_PATTERN.matcher(sql).find(); } @Override diff --git a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java index 4fe1d32ed..0e073549d 100644 --- a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java +++ b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java @@ -216,28 +216,6 @@ class JDBC4StatementTest { assertThat(rs.getInt(1)).isEqualTo(0); } - @Test - void testBatch_with_DDL_statements() throws SQLException { - // DDL statements should work in batch - stmt.addBatch("CREATE TABLE batch_test1 (id INTEGER);"); - stmt.addBatch("CREATE TABLE batch_test2 (id INTEGER);"); - stmt.addBatch("CREATE TABLE batch_test3 (id INTEGER);"); - - // Execute batch - int[] updateCounts = stmt.executeBatch(); - - // DDL statements typically return 0 for update count - assertThat(updateCounts).hasSize(3); - assertThat(updateCounts[0]).isEqualTo(0); - assertThat(updateCounts[1]).isEqualTo(0); - assertThat(updateCounts[2]).isEqualTo(0); - - // Verify tables were created by inserting data - assertDoesNotThrow(() -> stmt.execute("INSERT INTO batch_test1 VALUES (1);")); - assertDoesNotThrow(() -> stmt.execute("INSERT INTO batch_test2 VALUES (1);")); - assertDoesNotThrow(() -> stmt.execute("INSERT INTO batch_test3 VALUES (1);")); - } - @Test void testBatch_with_SELECT_should_throw_exception() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); @@ -335,4 +313,141 @@ class JDBC4StatementTest { int[] updateCounts = stmt.executeBatch(); assertThat(updateCounts).isEmpty(); } + + /** Tests for isBatchCompatibleStatement method */ + @Test + void testIsBatchCompatibleStatement_compatible_statements() { + JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; + + // Basic INSERT statements + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("INSERT INTO table VALUES (1, 2);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("insert into table values (1, 2);")); + assertTrue( + jdbc4Stmt.isBatchCompatibleStatement("INSERT INTO table (col1, col2) VALUES (1, 2);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("INSERT OR REPLACE INTO table VALUES (1);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("INSERT OR IGNORE INTO table VALUES (1);")); + + // INSERT with whitespace + assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" INSERT INTO table VALUES (1);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("\t\nINSERT INTO table VALUES (1);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" \n\t INSERT INTO table VALUES (1);")); + + // INSERT with comments + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ INSERT INTO table VALUES (1);")); + assertTrue( + jdbc4Stmt.isBatchCompatibleStatement( + "/* multi\nline\ncomment */ INSERT INTO table VALUES (1);")); + assertTrue( + jdbc4Stmt.isBatchCompatibleStatement("-- line comment\nINSERT INTO table VALUES (1);")); + assertTrue( + jdbc4Stmt.isBatchCompatibleStatement( + "-- comment 1\n-- comment 2\nINSERT INTO table VALUES (1);")); + + // Complex cases with multiple comments + assertTrue( + jdbc4Stmt.isBatchCompatibleStatement( + " /* comment */ -- another\n INSERT INTO table VALUES (1);")); + + // Basic UPDATE statements + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UPDATE table SET col = 1;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("update table set col = 1;")); + assertTrue( + jdbc4Stmt.isBatchCompatibleStatement("UPDATE table SET col1 = 1, col2 = 2 WHERE id = 3;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UPDATE OR REPLACE table SET col = 1;")); + + // UPDATE with whitespace + assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" UPDATE table SET col = 1;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("\t\nUPDATE table SET col = 1;")); + + // UPDATE with comments + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ UPDATE table SET col = 1;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("-- comment\nUPDATE table SET col = 1;")); + + // Basic DELETE statements + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("DELETE FROM table;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("delete from table;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("DELETE FROM table WHERE id = 1;")); + + // DELETE with whitespace + assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" DELETE FROM table;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("\t\nDELETE FROM table;")); + + // DELETE with comments + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ DELETE FROM table;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("-- comment\nDELETE FROM table;")); + } + + @Test + void testIsBatchCompatibleStatement_non_compatible_statements() { + JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; + + // SELECT statements should not be compatible + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("SELECT * FROM table;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("select * from table;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement(" SELECT * FROM table;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ SELECT * FROM table;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("-- comment\nSELECT * FROM table;")); + + // EXPLAIN statements + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("EXPLAIN SELECT * FROM table;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("EXPLAIN QUERY PLAN SELECT * FROM table;")); + + // PRAGMA statements + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("PRAGMA table_info(table);")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("PRAGMA foreign_keys = ON;")); + + // ANALYZE statements + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("ANALYZE;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("ANALYZE table;")); + + // WITH statements (CTEs) + assertFalse( + jdbc4Stmt.isBatchCompatibleStatement( + "WITH cte AS (SELECT * FROM table) SELECT * FROM cte;")); + + // VACUUM + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("VACUUM;")); + + // VALUES + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("VALUES (1, 2), (3, 4);")); + } + + @Test + void testIsBatchCompatibleStatement_edge_cases() { + JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; + + // Null and empty cases + assertFalse(jdbc4Stmt.isBatchCompatibleStatement(null)); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement(" ")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("\t\n")); + + // Comments only + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("/* comment only */")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("-- comment only")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ -- another comment")); + + // Keywords in wrong context (should not match if not at statement start) + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("SELECT * FROM table WHERE name = 'INSERT';")); + assertFalse( + jdbc4Stmt.isBatchCompatibleStatement("SELECT * FROM table WHERE action = 'DELETE';")); + + // Partial keywords (should not match) + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("INSER INTO table VALUES (1);")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("UPDAT table SET col = 1;")); + assertFalse(jdbc4Stmt.isBatchCompatibleStatement("DELET FROM table;")); + } + + @Test + void testIsBatchCompatibleStatement_case_insensitive() { + JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; + + // Mixed case should work + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("Insert INTO table VALUES (1);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("InSeRt INTO table VALUES (1);")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UPDATE table SET col = 1;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UpDaTe table SET col = 1;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("Delete FROM table;")); + assertTrue(jdbc4Stmt.isBatchCompatibleStatement("DeLeTe FROM table;")); + } } From fa8896d9ee75d152f4f905e6472dfe642251531d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EC=9A=B0?= Date: Sun, 24 Aug 2025 10:20:39 +0900 Subject: [PATCH 10/12] Nit --- .../tech/turso/jdbc4/JDBC4StatementTest.java | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java index 0e073549d..f12fa07a7 100644 --- a/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java +++ b/bindings/java/src/test/java/tech/turso/jdbc4/JDBC4StatementTest.java @@ -127,17 +127,13 @@ class JDBC4StatementTest { void testAddBatch_single_statement() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Add a single batch command stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); - // Execute batch int[] updateCounts = stmt.executeBatch(); - // Verify results assertThat(updateCounts).hasSize(1); assertThat(updateCounts[0]).isEqualTo(1); - // Verify data was inserted ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); assertTrue(rs.next()); assertThat(rs.getInt(1)).isEqualTo(1); @@ -147,21 +143,17 @@ class JDBC4StatementTest { void testAddBatch_multiple_statements() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Add multiple batch commands stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); stmt.addBatch("INSERT INTO batch_test VALUES (2, 'test2');"); stmt.addBatch("INSERT INTO batch_test VALUES (3, 'test3');"); - // Execute batch int[] updateCounts = stmt.executeBatch(); - // Verify results assertThat(updateCounts).hasSize(3); assertThat(updateCounts[0]).isEqualTo(1); assertThat(updateCounts[1]).isEqualTo(1); assertThat(updateCounts[2]).isEqualTo(1); - // Verify all data was inserted ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); assertTrue(rs.next()); assertThat(rs.getInt(1)).isEqualTo(3); @@ -171,19 +163,15 @@ class JDBC4StatementTest { void testAddBatch_with_updates_and_deletes() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Insert initial data stmt.execute( "INSERT INTO batch_test VALUES (1, 'initial1'), (2, 'initial2'), (3, 'initial3');"); - // Add batch commands with different operations stmt.addBatch("UPDATE batch_test SET value = 'updated';"); stmt.addBatch("DELETE FROM batch_test WHERE id = 2;"); stmt.addBatch("INSERT INTO batch_test VALUES (4, 'new');"); - // Execute batch int[] updateCounts = stmt.executeBatch(); - // Verify update counts assertThat(updateCounts).hasSize(3); assertThat(updateCounts[0]).isEqualTo(3); // UPDATE affected 3 row assertThat(updateCounts[1]).isEqualTo(1); // DELETE affected 1 row @@ -199,18 +187,14 @@ class JDBC4StatementTest { void testClearBatch() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Add batch commands stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); stmt.addBatch("INSERT INTO batch_test VALUES (2, 'test2');"); - // Clear the batch stmt.clearBatch(); - // Execute batch should return empty array int[] updateCounts = stmt.executeBatch(); assertThat(updateCounts).isEmpty(); - // Verify no data was inserted ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM batch_test;"); assertTrue(rs.next()); assertThat(rs.getInt(1)).isEqualTo(0); @@ -221,29 +205,23 @@ class JDBC4StatementTest { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); stmt.execute("INSERT INTO batch_test VALUES (1, 'test1');"); - // Add a SELECT statement to batch (not allowed) stmt.addBatch("INSERT INTO batch_test VALUES (2, 'test2');"); stmt.addBatch("SELECT * FROM batch_test;"); // This should cause an exception stmt.addBatch("INSERT INTO batch_test VALUES (3, 'test3');"); - // Execute batch should throw BatchUpdateException BatchUpdateException exception = assertThrows(BatchUpdateException.class, () -> stmt.executeBatch()); - // Verify exception message assertTrue(exception.getMessage().contains("Batch commands cannot return result sets")); - // Verify update counts for executed statements before the failure int[] updateCounts = exception.getUpdateCounts(); assertThat(updateCounts).hasSize(3); assertThat(updateCounts[0]).isEqualTo(1); // First INSERT succeeded assertThat(updateCounts[1]).isEqualTo(Statement.EXECUTE_FAILED); // SELECT failed - // The third statement may not have been executed depending on implementation } @Test - void testBatch_with_null_command_should_throw_exception() throws SQLException { - // Adding null command should throw SQLException + void testBatch_with_null_command_should_throw_exception() { assertThrows(SQLException.class, () -> stmt.addBatch(null)); } @@ -251,7 +229,6 @@ class JDBC4StatementTest { void testBatch_operations_on_closed_statement_should_throw_exception() throws SQLException { stmt.close(); - // All batch operations should throw SQLException on closed statement assertThrows(SQLException.class, () -> stmt.addBatch("INSERT INTO test VALUES (1);")); assertThrows(SQLException.class, () -> stmt.clearBatch()); assertThrows(SQLException.class, () -> stmt.executeBatch()); @@ -261,16 +238,13 @@ class JDBC4StatementTest { void testBatch_with_syntax_error_should_throw_exception() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Add batch commands with a syntax error stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); stmt.addBatch("INVALID SQL SYNTAX;"); // This should cause an exception stmt.addBatch("INSERT INTO batch_test VALUES (3, 'test3');"); - // Execute batch should throw BatchUpdateException BatchUpdateException exception = assertThrows(BatchUpdateException.class, () -> stmt.executeBatch()); - // Verify update counts show partial execution int[] updateCounts = exception.getUpdateCounts(); assertThat(updateCounts).hasSize(3); assertThat(updateCounts[0]).isEqualTo(1); // First INSERT succeeded @@ -279,10 +253,7 @@ class JDBC4StatementTest { @Test void testBatch_empty_batch_returns_empty_array() throws SQLException { - // Execute empty batch int[] updateCounts = stmt.executeBatch(); - - // Should return empty array assertThat(updateCounts).isEmpty(); } @@ -290,11 +261,9 @@ class JDBC4StatementTest { void testBatch_clears_after_successful_execution() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Add and execute batch stmt.addBatch("INSERT INTO batch_test VALUES (1, 'test1');"); stmt.executeBatch(); - // Execute batch again should return empty array (batch was cleared) int[] updateCounts = stmt.executeBatch(); assertThat(updateCounts).isEmpty(); } @@ -303,13 +272,10 @@ class JDBC4StatementTest { void testBatch_clears_after_failed_execution() throws SQLException { stmt.execute("CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT);"); - // Add batch with SELECT statement that will fail stmt.addBatch("SELECT * FROM batch_test;"); - // Execute batch should fail assertThrows(BatchUpdateException.class, () -> stmt.executeBatch()); - // Execute batch again should return empty array (batch was cleared after failure) int[] updateCounts = stmt.executeBatch(); assertThat(updateCounts).isEmpty(); } @@ -319,7 +285,6 @@ class JDBC4StatementTest { void testIsBatchCompatibleStatement_compatible_statements() { JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; - // Basic INSERT statements assertTrue(jdbc4Stmt.isBatchCompatibleStatement("INSERT INTO table VALUES (1, 2);")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("insert into table values (1, 2);")); assertTrue( @@ -327,12 +292,10 @@ class JDBC4StatementTest { assertTrue(jdbc4Stmt.isBatchCompatibleStatement("INSERT OR REPLACE INTO table VALUES (1);")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("INSERT OR IGNORE INTO table VALUES (1);")); - // INSERT with whitespace assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" INSERT INTO table VALUES (1);")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("\t\nINSERT INTO table VALUES (1);")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" \n\t INSERT INTO table VALUES (1);")); - // INSERT with comments assertTrue(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ INSERT INTO table VALUES (1);")); assertTrue( jdbc4Stmt.isBatchCompatibleStatement( @@ -343,36 +306,29 @@ class JDBC4StatementTest { jdbc4Stmt.isBatchCompatibleStatement( "-- comment 1\n-- comment 2\nINSERT INTO table VALUES (1);")); - // Complex cases with multiple comments assertTrue( jdbc4Stmt.isBatchCompatibleStatement( " /* comment */ -- another\n INSERT INTO table VALUES (1);")); - // Basic UPDATE statements assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UPDATE table SET col = 1;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("update table set col = 1;")); assertTrue( jdbc4Stmt.isBatchCompatibleStatement("UPDATE table SET col1 = 1, col2 = 2 WHERE id = 3;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UPDATE OR REPLACE table SET col = 1;")); - // UPDATE with whitespace assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" UPDATE table SET col = 1;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("\t\nUPDATE table SET col = 1;")); - // UPDATE with comments assertTrue(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ UPDATE table SET col = 1;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("-- comment\nUPDATE table SET col = 1;")); - // Basic DELETE statements assertTrue(jdbc4Stmt.isBatchCompatibleStatement("DELETE FROM table;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("delete from table;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("DELETE FROM table WHERE id = 1;")); - // DELETE with whitespace assertTrue(jdbc4Stmt.isBatchCompatibleStatement(" DELETE FROM table;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("\t\nDELETE FROM table;")); - // DELETE with comments assertTrue(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ DELETE FROM table;")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("-- comment\nDELETE FROM table;")); } @@ -381,34 +337,27 @@ class JDBC4StatementTest { void testIsBatchCompatibleStatement_non_compatible_statements() { JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; - // SELECT statements should not be compatible assertFalse(jdbc4Stmt.isBatchCompatibleStatement("SELECT * FROM table;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("select * from table;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement(" SELECT * FROM table;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ SELECT * FROM table;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("-- comment\nSELECT * FROM table;")); - // EXPLAIN statements assertFalse(jdbc4Stmt.isBatchCompatibleStatement("EXPLAIN SELECT * FROM table;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("EXPLAIN QUERY PLAN SELECT * FROM table;")); - // PRAGMA statements assertFalse(jdbc4Stmt.isBatchCompatibleStatement("PRAGMA table_info(table);")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("PRAGMA foreign_keys = ON;")); - // ANALYZE statements assertFalse(jdbc4Stmt.isBatchCompatibleStatement("ANALYZE;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("ANALYZE table;")); - // WITH statements (CTEs) assertFalse( jdbc4Stmt.isBatchCompatibleStatement( "WITH cte AS (SELECT * FROM table) SELECT * FROM cte;")); - // VACUUM assertFalse(jdbc4Stmt.isBatchCompatibleStatement("VACUUM;")); - // VALUES assertFalse(jdbc4Stmt.isBatchCompatibleStatement("VALUES (1, 2), (3, 4);")); } @@ -416,23 +365,19 @@ class JDBC4StatementTest { void testIsBatchCompatibleStatement_edge_cases() { JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; - // Null and empty cases assertFalse(jdbc4Stmt.isBatchCompatibleStatement(null)); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement(" ")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("\t\n")); - // Comments only assertFalse(jdbc4Stmt.isBatchCompatibleStatement("/* comment only */")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("-- comment only")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("/* comment */ -- another comment")); - // Keywords in wrong context (should not match if not at statement start) assertFalse(jdbc4Stmt.isBatchCompatibleStatement("SELECT * FROM table WHERE name = 'INSERT';")); assertFalse( jdbc4Stmt.isBatchCompatibleStatement("SELECT * FROM table WHERE action = 'DELETE';")); - // Partial keywords (should not match) assertFalse(jdbc4Stmt.isBatchCompatibleStatement("INSER INTO table VALUES (1);")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("UPDAT table SET col = 1;")); assertFalse(jdbc4Stmt.isBatchCompatibleStatement("DELET FROM table;")); @@ -442,7 +387,6 @@ class JDBC4StatementTest { void testIsBatchCompatibleStatement_case_insensitive() { JDBC4Statement jdbc4Stmt = (JDBC4Statement) stmt; - // Mixed case should work assertTrue(jdbc4Stmt.isBatchCompatibleStatement("Insert INTO table VALUES (1);")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("InSeRt INTO table VALUES (1);")); assertTrue(jdbc4Stmt.isBatchCompatibleStatement("UPDATE table SET col = 1;")); From 7057c97cfe90ed7f68f95155444d3bfb6aee19fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EC=9A=B0?= Date: Sun, 24 Aug 2025 10:25:14 +0900 Subject: [PATCH 11/12] Remove .rustc_info.json --- .../java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java | 2 -- bindings/java/src/test/resources/turso/.rustc_info.json | 1 - 2 files changed, 3 deletions(-) delete mode 100644 bindings/java/src/test/resources/turso/.rustc_info.json diff --git a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java index 97bf65af9..eb31c8d0b 100644 --- a/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java +++ b/bindings/java/src/main/java/tech/turso/jdbc4/JDBC4Statement.java @@ -331,8 +331,6 @@ public class JDBC4Statement implements Statement { return false; } - // Check if the SQL matches batch-compatible patterns (DML and DDL) - // This will return false for SELECT, EXPLAIN, PRAGMA, SHOW, etc. return BATCH_COMPATIBLE_PATTERN.matcher(sql).find(); } diff --git a/bindings/java/src/test/resources/turso/.rustc_info.json b/bindings/java/src/test/resources/turso/.rustc_info.json deleted file mode 100644 index 68aeec704..000000000 --- a/bindings/java/src/test/resources/turso/.rustc_info.json +++ /dev/null @@ -1 +0,0 @@ -{"rustc_fingerprint":4908805493570777128,"outputs":{"11769396151817893336":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/seonwoo960000/.rustup/toolchains/1.88.0-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\ntokio_unstable\nunix\n","stderr":""},"10375286590057847751":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: aarch64-apple-darwin\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""},"10459017571985955891":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/seonwoo960000/.rustup/toolchains/1.88.0-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\ntokio_unstable\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file From 22c9cb661858a6218d0b6342288094fc285caee9 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Sun, 24 Aug 2025 08:17:20 +0300 Subject: [PATCH 12/12] s/PerConnEncryptionContext/EncryptionContext/ --- bindings/javascript/src/lib.rs | 6 +++--- core/lib.rs | 2 +- core/storage/database.rs | 16 ++++++++-------- core/storage/encryption.rs | 6 +++--- core/storage/pager.rs | 8 ++++---- core/storage/sqlite3_ondisk.rs | 6 +++--- core/storage/wal.rs | 8 ++++---- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bindings/javascript/src/lib.rs b/bindings/javascript/src/lib.rs index 711c00b8a..40097eff5 100644 --- a/bindings/javascript/src/lib.rs +++ b/bindings/javascript/src/lib.rs @@ -569,7 +569,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { fn read_page( &self, page_idx: usize, - _encryption_ctx: Option<&turso_core::PerConnEncryptionContext>, + _encryption_ctx: Option<&turso_core::EncryptionContext>, c: turso_core::Completion, ) -> turso_core::Result { let r = c.as_read(); @@ -586,7 +586,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { &self, page_idx: usize, buffer: Arc, - _encryption_ctx: Option<&turso_core::PerConnEncryptionContext>, + _encryption_ctx: Option<&turso_core::EncryptionContext>, c: turso_core::Completion, ) -> turso_core::Result { let size = buffer.len(); @@ -599,7 +599,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { first_page_idx: usize, page_size: usize, buffers: Vec>, - _encryption_ctx: Option<&turso_core::PerConnEncryptionContext>, + _encryption_ctx: Option<&turso_core::EncryptionContext>, c: turso_core::Completion, ) -> turso_core::Result { let pos = first_page_idx.saturating_sub(1) * page_size; diff --git a/core/lib.rs b/core/lib.rs index 8ef042dc1..5ba3a4faa 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -75,7 +75,7 @@ use std::{ }; #[cfg(feature = "fs")] use storage::database::DatabaseFile; -pub use storage::encryption::{EncryptionKey, PerConnEncryptionContext}; +pub use storage::encryption::{EncryptionKey, EncryptionContext}; use storage::page_cache::DumbLruPageCache; use storage::pager::{AtomicDbState, DbState}; use storage::sqlite3_ondisk::PageSize; diff --git a/core/storage/database.rs b/core/storage/database.rs index e2b27b302..d608558fc 100644 --- a/core/storage/database.rs +++ b/core/storage/database.rs @@ -1,5 +1,5 @@ use crate::error::LimboError; -use crate::storage::encryption::PerConnEncryptionContext; +use crate::storage::encryption::EncryptionContext; use crate::{io::Completion, Buffer, CompletionError, Result}; use std::sync::Arc; use tracing::{instrument, Level}; @@ -15,14 +15,14 @@ pub trait DatabaseStorage: Send + Sync { fn read_page( &self, page_idx: usize, - encryption_ctx: Option<&PerConnEncryptionContext>, + encryption_ctx: Option<&EncryptionContext>, c: Completion, ) -> Result; fn write_page( &self, page_idx: usize, buffer: Arc, - encryption_ctx: Option<&PerConnEncryptionContext>, + encryption_ctx: Option<&EncryptionContext>, c: Completion, ) -> Result; fn write_pages( @@ -30,7 +30,7 @@ pub trait DatabaseStorage: Send + Sync { first_page_idx: usize, page_size: usize, buffers: Vec>, - encryption_ctx: Option<&PerConnEncryptionContext>, + encryption_ctx: Option<&EncryptionContext>, c: Completion, ) -> Result; fn sync(&self, c: Completion) -> Result; @@ -59,7 +59,7 @@ impl DatabaseStorage for DatabaseFile { fn read_page( &self, page_idx: usize, - encryption_ctx: Option<&PerConnEncryptionContext>, + encryption_ctx: Option<&EncryptionContext>, c: Completion, ) -> Result { let r = c.as_read(); @@ -111,7 +111,7 @@ impl DatabaseStorage for DatabaseFile { &self, page_idx: usize, buffer: Arc, - encryption_ctx: Option<&PerConnEncryptionContext>, + encryption_ctx: Option<&EncryptionContext>, c: Completion, ) -> Result { let buffer_size = buffer.len(); @@ -135,7 +135,7 @@ impl DatabaseStorage for DatabaseFile { first_page_idx: usize, page_size: usize, buffers: Vec>, - encryption_key: Option<&PerConnEncryptionContext>, + encryption_key: Option<&EncryptionContext>, c: Completion, ) -> Result { assert!(first_page_idx > 0); @@ -187,7 +187,7 @@ impl DatabaseFile { fn encrypt_buffer( page_idx: usize, buffer: Arc, - ctx: &PerConnEncryptionContext, + ctx: &EncryptionContext, ) -> Arc { let encrypted_data = ctx.encrypt_page(buffer.as_slice(), page_idx).unwrap(); Arc::new(Buffer::new(encrypted_data.to_vec())) diff --git a/core/storage/encryption.rs b/core/storage/encryption.rs index 8c8f7bd44..97836d3d1 100644 --- a/core/storage/encryption.rs +++ b/core/storage/encryption.rs @@ -113,12 +113,12 @@ impl std::fmt::Debug for Cipher { } #[derive(Clone)] -pub struct PerConnEncryptionContext { +pub struct EncryptionContext { cipher_mode: CipherMode, cipher: Cipher, } -impl PerConnEncryptionContext { +impl EncryptionContext { pub fn new(key: &EncryptionKey) -> Result { let cipher_mode = CipherMode::Aes256Gcm; let required_size = cipher_mode.required_key_size(); @@ -281,7 +281,7 @@ mod tests { }; let key = EncryptionKey::from_string("alice and bob use encryption on database"); - let ctx = PerConnEncryptionContext::new(&key).unwrap(); + let ctx = EncryptionContext::new(&key).unwrap(); let page_id = 42; let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap(); diff --git a/core/storage/pager.rs b/core/storage/pager.rs index ca9f32cf9..4512b93c7 100644 --- a/core/storage/pager.rs +++ b/core/storage/pager.rs @@ -29,7 +29,7 @@ use super::page_cache::{CacheError, CacheResizeResult, DumbLruPageCache, PageCac use super::sqlite3_ondisk::begin_write_btree_page; use super::wal::CheckpointMode; use crate::storage::encryption::{ - EncryptionKey, PerConnEncryptionContext, ENCRYPTION_METADATA_SIZE, + EncryptionKey, EncryptionContext, ENCRYPTION_METADATA_SIZE, }; /// SQLite's default maximum page count @@ -493,7 +493,7 @@ pub struct Pager { header_ref_state: RefCell, #[cfg(not(feature = "omit_autovacuum"))] btree_create_vacuum_full_state: Cell, - pub(crate) encryption_ctx: RefCell>, + pub(crate) encryption_ctx: RefCell>, } #[derive(Debug, Clone)] @@ -1137,7 +1137,7 @@ impl Pager { page_idx: usize, page: PageRef, allow_empty_read: bool, - encryption_key: Option<&PerConnEncryptionContext>, + encryption_key: Option<&EncryptionContext>, ) -> Result { sqlite3_ondisk::begin_read_page( self.db_file.clone(), @@ -2112,7 +2112,7 @@ impl Pager { } pub fn set_encryption_context(&self, key: &EncryptionKey) { - let encryption_ctx = PerConnEncryptionContext::new(key).unwrap(); + let encryption_ctx = EncryptionContext::new(key).unwrap(); self.encryption_ctx.replace(Some(encryption_ctx.clone())); let Some(wal) = self.wal.as_ref() else { return }; wal.borrow_mut().set_encryption_context(encryption_ctx) diff --git a/core/storage/sqlite3_ondisk.rs b/core/storage/sqlite3_ondisk.rs index d1a37615d..8ec4c861f 100644 --- a/core/storage/sqlite3_ondisk.rs +++ b/core/storage/sqlite3_ondisk.rs @@ -59,7 +59,7 @@ use crate::storage::btree::offset::{ use crate::storage::btree::{payload_overflow_threshold_max, payload_overflow_threshold_min}; use crate::storage::buffer_pool::BufferPool; use crate::storage::database::DatabaseStorage; -use crate::storage::encryption::PerConnEncryptionContext; +use crate::storage::encryption::EncryptionContext; use crate::storage::pager::Pager; use crate::storage::wal::READMARK_NOT_USED; use crate::types::{RawSlice, RefValue, SerialType, SerialTypeKind, TextRef, TextSubtype}; @@ -870,7 +870,7 @@ pub fn begin_read_page( page: PageRef, page_idx: usize, allow_empty_read: bool, - encryption_key: Option<&PerConnEncryptionContext>, + encryption_key: Option<&EncryptionContext>, ) -> Result { tracing::trace!("begin_read_btree_page(page_idx = {})", page_idx); let buf = buffer_pool.get_page(); @@ -965,7 +965,7 @@ pub fn write_pages_vectored( pager: &Pager, batch: BTreeMap>, done_flag: Arc, - encryption_key: Option<&PerConnEncryptionContext>, + encryption_key: Option<&EncryptionContext>, ) -> Result> { if batch.is_empty() { done_flag.store(true, Ordering::Relaxed); diff --git a/core/storage/wal.rs b/core/storage/wal.rs index 0a903f4b3..3b07d80f8 100644 --- a/core/storage/wal.rs +++ b/core/storage/wal.rs @@ -17,7 +17,7 @@ use super::sqlite3_ondisk::{self, checksum_wal, WalHeader, WAL_MAGIC_BE, WAL_MAG use crate::fast_lock::SpinLock; use crate::io::{clock, File, IO}; use crate::result::LimboResult; -use crate::storage::encryption::PerConnEncryptionContext; +use crate::storage::encryption::EncryptionContext; use crate::storage::sqlite3_ondisk::{ begin_read_wal_frame, begin_read_wal_frame_raw, finish_read_page, prepare_wal_frame, write_pages_vectored, PageSize, WAL_FRAME_HEADER_SIZE, WAL_HEADER_SIZE, @@ -297,7 +297,7 @@ pub trait Wal: Debug { /// Return unique set of pages changed **after** frame_watermark position and until current WAL session max_frame_no fn changed_pages_after(&self, frame_watermark: u64) -> Result>; - fn set_encryption_context(&mut self, ctx: PerConnEncryptionContext); + fn set_encryption_context(&mut self, ctx: EncryptionContext); #[cfg(debug_assertions)] fn as_any(&self) -> &dyn std::any::Any; @@ -568,7 +568,7 @@ pub struct WalFile { /// Manages locks needed for checkpointing checkpoint_guard: Option, - encryption_ctx: RefCell>, + encryption_ctx: RefCell>, } impl fmt::Debug for WalFile { @@ -1374,7 +1374,7 @@ impl Wal for WalFile { self } - fn set_encryption_context(&mut self, ctx: PerConnEncryptionContext) { + fn set_encryption_context(&mut self, ctx: EncryptionContext) { self.encryption_ctx.replace(Some(ctx)); } }