diff --git a/.github/actions/android/action.yml b/.github/actions/android/action.yml index 6fdfee70..47a2ebf4 100644 --- a/.github/actions/android/action.yml +++ b/.github/actions/android/action.yml @@ -25,8 +25,8 @@ runs: - name: Setup shell: bash run: | - rustup toolchain install nightly-2025-10-31-x86_64-unknown-linux-gnu - rustup component add rust-src --toolchain nightly-2025-10-31-x86_64-unknown-linux-gnu + rustup toolchain install nightly-2025-12-05-x86_64-unknown-linux-gnu + rustup component add rust-src --toolchain nightly-2025-12-05-x86_64-unknown-linux-gnu rustup target add \ aarch64-linux-android \ armv7-linux-androideabi \ diff --git a/.github/actions/linux/action.yml b/.github/actions/linux/action.yml index 1511b925..d6101d12 100644 --- a/.github/actions/linux/action.yml +++ b/.github/actions/linux/action.yml @@ -7,7 +7,7 @@ runs: - name: Install Rust Nightly uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2025-10-31 + toolchain: nightly-2025-12-05 components: rust-src targets: aarch64-unknown-linux-gnu,x86_64-unknown-linux-gnu,i686-unknown-linux-gnu,riscv64gc-unknown-linux-gnu,armv7-unknown-linux-gnueabihf diff --git a/.github/actions/macos/action.yml b/.github/actions/macos/action.yml index b7a5be21..233bcddc 100644 --- a/.github/actions/macos/action.yml +++ b/.github/actions/macos/action.yml @@ -7,7 +7,7 @@ runs: - name: Install Rust Nightly uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2025-10-31 + toolchain: nightly-2025-12-05 components: rust-src targets: x86_64-apple-darwin,aarch64-apple-darwin diff --git a/.github/actions/wasm/action.yml b/.github/actions/wasm/action.yml index 475685fb..900d9db3 100644 --- a/.github/actions/wasm/action.yml +++ b/.github/actions/wasm/action.yml @@ -7,7 +7,7 @@ runs: - name: Install Rust Nightly uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2025-10-31 + toolchain: nightly-2025-12-05 components: rust-src - name: Setup emsdk diff --git a/.github/actions/windows/action.yml b/.github/actions/windows/action.yml index 9864f1d9..5cda964c 100644 --- a/.github/actions/windows/action.yml +++ b/.github/actions/windows/action.yml @@ -7,7 +7,7 @@ runs: - name: Install Rust Nightly uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2025-10-31 + toolchain: nightly-2025-12-05 components: rust-src targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc,i686-pc-windows-msvc diff --git a/.github/actions/xcframework/action.yml b/.github/actions/xcframework/action.yml index a303c7d1..292f928d 100644 --- a/.github/actions/xcframework/action.yml +++ b/.github/actions/xcframework/action.yml @@ -7,8 +7,8 @@ runs: - name: Setup shell: bash run: | - rustup toolchain install nightly-2025-10-31-aarch64-apple-darwin - rustup component add rust-src --toolchain nightly-2025-10-31-aarch64-apple-darwin + rustup toolchain install nightly-2025-12-05-aarch64-apple-darwin + rustup component add rust-src --toolchain nightly-2025-12-05-aarch64-apple-darwin rustup target add \ x86_64-apple-darwin \ aarch64-apple-darwin \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fe28612..c02ec5f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -81,7 +81,7 @@ jobs: - name: Install Rust Nightly uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2025-10-31 + toolchain: nightly-2025-12-05 components: rust-src,rustfmt,clippy - name: Check formatting @@ -201,15 +201,17 @@ jobs: - name: Install Rust Nightly uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly-2025-10-31 + toolchain: nightly-2025-12-05 components: rust-src - name: Install valgrind run: sudo apt update && sudo apt install -y valgrind - name: Install Cargo Valgrind - run: | - cargo install cargo-valgrind + # TODO: Use released version. Currently we rely on the git version while we wait for this + # to be released: https://github.com/jfrimmel/cargo-valgrind/commit/408c0b4fb56e84eddc2bb09c88a11ba3adc0c188 + run: cargo install --git https://github.com/jfrimmel/cargo-valgrind cargo-valgrind + #run: cargo install cargo-valgrind - name: Test Core run: | diff --git a/crates/core/src/crud_vtab.rs b/crates/core/src/crud_vtab.rs index d8644806..4c0f4070 100644 --- a/crates/core/src/crud_vtab.rs +++ b/crates/core/src/crud_vtab.rs @@ -15,7 +15,7 @@ use crate::error::PowerSyncError; use crate::ext::SafeManagedStmt; use crate::schema::TableInfoFlags; use crate::state::DatabaseState; -use crate::util::MAX_OP_ID; +use crate::utils::MAX_OP_ID; use crate::vtab_util::*; const MANUAL_NAME: &CStr = c"powersync_crud_"; diff --git a/crates/core/src/fix_data.rs b/crates/core/src/fix_data.rs index f0026b40..89243e33 100644 --- a/crates/core/src/fix_data.rs +++ b/crates/core/src/fix_data.rs @@ -6,11 +6,11 @@ use alloc::string::String; use crate::create_sqlite_optional_text_fn; use crate::error::{PSResult, PowerSyncError}; use crate::schema::inspection::ExistingTable; +use crate::utils::SqlBuffer; use powersync_sqlite_nostd::{self as sqlite, ColumnType, Value}; use powersync_sqlite_nostd::{Connection, Context, ResultCode}; use crate::ext::SafeManagedStmt; -use crate::util::quote_identifier; // Apply a data migration to fix any existing data affected by the issue // fixed in v0.3.5. @@ -33,7 +33,7 @@ pub fn apply_v035_fix(db: *mut sqlite::sqlite3) -> Result { continue; }; - let quoted = quote_identifier(full_name); + let quoted = SqlBuffer::quote_identifier(full_name); // language=SQLite let statement = db.prepare_v2(&format!( diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 31fdd30a..9b173974 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -28,7 +28,7 @@ mod state; mod sync; mod sync_local; mod update_hooks; -mod util; +mod utils; mod uuid; mod version; mod view_admin; diff --git a/crates/core/src/schema/inspection.rs b/crates/core/src/schema/inspection.rs index 663602e8..34b1aaa6 100644 --- a/crates/core/src/schema/inspection.rs +++ b/crates/core/src/schema/inspection.rs @@ -5,7 +5,7 @@ use powersync_sqlite_nostd::Connection; use powersync_sqlite_nostd::{self as sqlite, ResultCode}; use crate::error::{PSResult, PowerSyncError}; -use crate::util::quote_identifier; +use crate::utils::SqlBuffer; /// An existing PowerSync-managed view that was found in the schema. #[derive(PartialEq)] @@ -64,7 +64,7 @@ SELECT } pub fn drop_by_name(db: *mut sqlite::sqlite3, name: &str) -> Result<(), PowerSyncError> { - let q = format!("DROP VIEW IF EXISTS {:}", quote_identifier(name)); + let q = format!("DROP VIEW IF EXISTS {:}", SqlBuffer::quote_identifier(name)); db.exec_safe(&q)?; Ok(()) } diff --git a/crates/core/src/schema/management.rs b/crates/core/src/schema/management.rs index 66a3d8b0..98fed058 100644 --- a/crates/core/src/schema/management.rs +++ b/crates/core/src/schema/management.rs @@ -7,6 +7,7 @@ use alloc::string::String; use alloc::vec::Vec; use alloc::{format, vec}; use core::ffi::c_int; +use core::fmt::Write; use powersync_sqlite_nostd as sqlite; use powersync_sqlite_nostd::Context; @@ -15,8 +16,9 @@ use sqlite::{Connection, ResultCode, Value}; use crate::error::{PSResult, PowerSyncError}; use crate::ext::ExtendedDatabase; use crate::schema::inspection::{ExistingTable, ExistingView}; +use crate::schema::table_info::Index; use crate::state::DatabaseState; -use crate::util::{quote_identifier, quote_json_path}; +use crate::utils::SqlBuffer; use crate::views::{ powersync_trigger_delete_sql, powersync_trigger_insert_sql, powersync_trigger_update_sql, powersync_view_sql, @@ -54,7 +56,7 @@ fn update_tables(db: *mut sqlite::sqlite3, schema: &Schema) -> Result<(), PowerS } // New table. - let quoted_internal_name = quote_identifier(&table.internal_name()); + let quoted_internal_name = SqlBuffer::quote_identifier(&table.internal_name()); db.exec_safe(&format!( "CREATE TABLE {:}(id TEXT PRIMARY KEY NOT NULL, data TEXT)", @@ -88,7 +90,7 @@ fn update_tables(db: *mut sqlite::sqlite3, schema: &Schema) -> Result<(), PowerS db.exec_text( &format!( "INSERT INTO ps_untyped(type, id, data) SELECT ?, id, data FROM {:}", - quote_identifier(&remaining.internal_name) + SqlBuffer::quote_identifier(&remaining.internal_name) ), &remaining.name, ) @@ -100,13 +102,39 @@ fn update_tables(db: *mut sqlite::sqlite3, schema: &Schema) -> Result<(), PowerS // We cannot have any open queries on sqlite_master at the point that we drop tables, otherwise // we get "table is locked" errors. for remaining in existing_tables.values() { - let q = format!("DROP TABLE {:}", quote_identifier(&remaining.internal_name)); + let q = format!( + "DROP TABLE {:}", + SqlBuffer::quote_identifier(&remaining.internal_name) + ); db.exec_safe(&q).into_db_result(db)?; } Ok(()) } +fn create_index_stmt(table_name: &str, index_name: &str, index: &Index) -> String { + let mut sql = SqlBuffer::new(); + sql.push_str("CREATE INDEX "); + let _ = sql.identifier().write_str(&index_name); + sql.push_str(" ON "); + let _ = sql.identifier().write_str(&table_name); + sql.push_char('('); + { + let mut sql = sql.comma_separated(); + for indexed_column in &index.columns { + let sql = sql.element(); + sql.json_extract_and_cast("data", &indexed_column.name, &indexed_column.type_name); + + if !indexed_column.ascending { + sql.push_str(" DESC"); + } + } + } + sql.push_char(')'); + + sql.sql +} + fn update_indexes(db: *mut sqlite::sqlite3, schema: &Schema) -> Result<(), PowerSyncError> { let mut statements: Vec = alloc::vec![]; let mut expected_index_names: Vec = vec![]; @@ -136,32 +164,14 @@ fn update_indexes(db: *mut sqlite::sqlite3, schema: &Schema) -> Result<(), Power result }; - let mut column_values: Vec = alloc::vec![]; - for indexed_column in &index.columns { - let mut value = format!( - "CAST(json_extract(data, {:}) as {:})", - quote_json_path(&indexed_column.name), - &indexed_column.type_name - ); - - if !indexed_column.ascending { - value += " DESC"; - } - - column_values.push(value); - } - - let sql = format!( - "CREATE INDEX {} ON {}({})", - quote_identifier(&index_name), - quote_identifier(&table_name), - column_values.join(", ") - ); - + let sql = create_index_stmt(&table_name, &index_name, index); if existing_sql.is_none() { statements.push(sql); } else if existing_sql != Some(&sql) { - statements.push(format!("DROP INDEX {}", quote_identifier(&index_name))); + statements.push(format!( + "DROP INDEX {}", + SqlBuffer::quote_identifier(&index_name) + )); statements.push(sql); } @@ -190,7 +200,7 @@ SELECT while statement.step()? == ResultCode::ROW { let name = statement.column_text(0)?; - statements.push(format!("DROP INDEX {}", quote_identifier(name))); + statements.push(format!("DROP INDEX {}", SqlBuffer::quote_identifier(name))); } } @@ -294,3 +304,40 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() Ok(()) } + +#[cfg(test)] +mod test { + use alloc::{string::ToString, vec}; + + use crate::schema::table_info::{Index, IndexedColumn}; + + use super::create_index_stmt; + + #[test] + fn test_create_index() { + let stmt = create_index_stmt( + "table", + "index", + &Index { + name: "unused".to_string(), + columns: vec![ + IndexedColumn { + name: "a".to_string(), + ascending: true, + type_name: "text".to_string(), + }, + IndexedColumn { + name: "b".to_string(), + ascending: false, + type_name: "integer".to_string(), + }, + ], + }, + ); + + assert_eq!( + stmt, + r#"CREATE INDEX "index" ON "table"(CAST(json_extract(data, '$.a') as text), CAST(json_extract(data, '$.b') as integer) DESC)"# + ) + } +} diff --git a/crates/core/src/schema/table_info.rs b/crates/core/src/schema/table_info.rs index 010cf73a..aec1e4bc 100644 --- a/crates/core/src/schema/table_info.rs +++ b/crates/core/src/schema/table_info.rs @@ -162,6 +162,12 @@ impl TableInfoFlags { } pub const fn insert_only(self) -> bool { + // Note: insert_only is incompatible with local_only. For backwards compatibility, we want + // to silently ignore insert_only if local_only is set. + if self.local_only() { + return false; + } + self.0 & Self::INSERT_ONLY != 0 } diff --git a/crates/core/src/sync/interface.rs b/crates/core/src/sync/interface.rs index 7dd86b3e..4bc43d13 100644 --- a/crates/core/src/sync/interface.rs +++ b/crates/core/src/sync/interface.rs @@ -23,7 +23,7 @@ use serde_json::value::RawValue; use sqlite::{ResultCode, Value}; use crate::sync::BucketPriority; -use crate::util::JsonString; +use crate::utils::JsonString; /// Payload provided by SDKs when requesting a sync iteration. #[derive(Deserialize)] diff --git a/crates/core/src/sync/storage_adapter.rs b/crates/core/src/sync/storage_adapter.rs index 2f1e6f82..025b1875 100644 --- a/crates/core/src/sync/storage_adapter.rs +++ b/crates/core/src/sync/storage_adapter.rs @@ -18,7 +18,7 @@ use crate::{ sync_status::{ActiveStreamSubscription, DownloadSyncStatus, SyncPriorityStatus}, }, sync_local::{PartialSyncOperation, SyncOperation}, - util::{JsonString, column_nullable}, + utils::{JsonString, column_nullable}, }; use super::{ diff --git a/crates/core/src/sync/subscriptions.rs b/crates/core/src/sync/subscriptions.rs index 74bad2d1..52dc5a81 100644 --- a/crates/core/src/sync/subscriptions.rs +++ b/crates/core/src/sync/subscriptions.rs @@ -9,7 +9,7 @@ use crate::{ error::{PSResult, PowerSyncError}, ext::SafeManagedStmt, sync::BucketPriority, - util::JsonString, + utils::JsonString, }; /// A row in the `ps_stream_subscriptions` table. diff --git a/crates/core/src/sync/sync_status.rs b/crates/core/src/sync/sync_status.rs index 42f2fddb..50d11ea0 100644 --- a/crates/core/src/sync/sync_status.rs +++ b/crates/core/src/sync/sync_status.rs @@ -24,7 +24,7 @@ use crate::{ checkpoint::OwnedBucketChecksum, storage_adapter::StorageAdapter, subscriptions::LocallyTrackedSubscription, }, - util::JsonString, + utils::JsonString, }; use super::{ diff --git a/crates/core/src/sync_local.rs b/crates/core/src/sync_local.rs index 6796d0ba..69a5572a 100644 --- a/crates/core/src/sync_local.rs +++ b/crates/core/src/sync_local.rs @@ -1,6 +1,6 @@ use alloc::collections::btree_map::BTreeMap; use alloc::format; -use alloc::string::String; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; @@ -10,11 +10,11 @@ use crate::schema::inspection::ExistingTable; use crate::schema::{PendingStatement, PendingStatementValue, RawTable, Schema}; use crate::state::DatabaseState; use crate::sync::BucketPriority; +use crate::utils::SqlBuffer; use powersync_sqlite_nostd::{self as sqlite, Destructor, ManagedStmt, Value}; use powersync_sqlite_nostd::{ColumnType, Connection, ResultCode}; use crate::ext::SafeManagedStmt; -use crate::util::quote_internal_name; pub fn sync_local( state: &DatabaseState, @@ -171,24 +171,25 @@ impl<'a> SyncOperation<'a> { } } } else { - let quoted = quote_internal_name(type_name, false); - // is_err() is essentially a NULL check here. // NULL data means no PUT operations found, so we delete the row. if data.is_err() { // DELETE let delete_statement = match &last_delete { - Some(stmt) if &*stmt.table == &*quoted => &stmt.statement, + Some(stmt) if stmt.table == type_name => &stmt.statement, _ => { // Prepare statement when the table changed - let statement = self - .db - .prepare_v2(&format!("DELETE FROM {} WHERE id = ?", quoted)) - .into_db_result(self.db)?; + let mut statement = SqlBuffer::new(); + statement.push_str("DELETE FROM "); + statement.quote_internal_name(type_name, false); + statement.push_str(" WHERE id = ?"); + + let statement = + self.db.prepare_v2(&statement.sql).into_db_result(self.db)?; &last_delete .insert(CachedStatement { - table: quoted.clone(), + table: type_name.to_string(), statement, }) .statement @@ -201,20 +202,20 @@ impl<'a> SyncOperation<'a> { } else { // INSERT/UPDATE let insert_statement = match &last_insert { - Some(stmt) if &*stmt.table == &*quoted => &stmt.statement, + Some(stmt) if stmt.table == type_name => &stmt.statement, _ => { // Prepare statement when the table changed - let statement = self - .db - .prepare_v2(&format!( - "REPLACE INTO {}(id, data) VALUES(?, ?)", - quoted - )) - .into_db_result(self.db)?; + let mut statement = SqlBuffer::new(); + statement.push_str("REPLACE INTO "); + statement.quote_internal_name(type_name, false); + statement.push_str("(id, data) VALUES (?, ?)"); + + let statement = + self.db.prepare_v2(&statement.sql).into_db_result(self.db)?; &last_insert .insert(CachedStatement { - table: quoted.clone(), + table: type_name.to_string(), statement, }) .statement diff --git a/crates/core/src/util.rs b/crates/core/src/utils/mod.rs similarity index 58% rename from crates/core/src/util.rs rename to crates/core/src/utils/mod.rs index e42b4c9a..cfafe32e 100644 --- a/crates/core/src/util.rs +++ b/crates/core/src/utils/mod.rs @@ -1,71 +1,16 @@ -extern crate alloc; +mod sql_buffer; -use core::fmt::{Display, Write}; +use core::{cmp::Ordering, fmt::Display, hash::Hash}; -use alloc::format; -use alloc::string::{String, ToString}; -use core::{cmp::Ordering, hash::Hash}; - -use alloc::boxed::Box; +use alloc::{boxed::Box, string::String}; use powersync_sqlite_nostd::{ColumnType, ManagedStmt}; use serde::Serialize; use serde_json::value::RawValue; +pub use sql_buffer::{InsertIntoCrud, SqlBuffer}; use crate::error::PowerSyncError; -#[cfg(not(feature = "getrandom"))] -use crate::sqlite; - use uuid::Uuid; -#[cfg(not(feature = "getrandom"))] -use uuid::Builder; - -pub fn quote_string(s: &str) -> String { - return QuotedString(s).to_string(); -} - -pub fn quote_json_path(s: &str) -> String { - quote_string(&format!("$.{:}", s)) -} - -pub fn quote_identifier(name: &str) -> String { - format!("\"{:}\"", name.replace("\"", "\"\"")) -} - -pub fn quote_internal_name(name: &str, local_only: bool) -> String { - if local_only { - quote_identifier_prefixed("ps_data_local__", name) - } else { - quote_identifier_prefixed("ps_data__", name) - } -} - -/// A string that [Display]s as a SQLite string literal. -pub struct QuotedString<'a>(pub &'a str); - -impl<'a> Display for QuotedString<'a> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - const SINGLE_QUOTE: char = '\''; - const ESCAPE_SEQUENCE: &'static str = "''"; - - f.write_char(SINGLE_QUOTE)?; - - for (i, group) in self.0.split(SINGLE_QUOTE).enumerate() { - if i != 0 { - f.write_str(ESCAPE_SEQUENCE)?; - } - - f.write_str(group)?; - } - - f.write_char(SINGLE_QUOTE) - } -} - -pub fn quote_identifier_prefixed(prefix: &str, name: &str) -> String { - return format!("\"{:}{:}\"", prefix, name.replace("\"", "\"\"")); -} - /// Calls [read] to read a column if it's not null, otherwise returns [None]. #[inline] pub fn column_nullable Result>( @@ -164,6 +109,9 @@ pub fn gen_uuid() -> Uuid { // Rather avoid this version for most builds. #[cfg(not(feature = "getrandom"))] pub fn gen_uuid() -> Uuid { + use crate::sqlite; + use uuid::Builder; + let mut random_bytes: [u8; 16] = [0; 16]; sqlite::randomness(&mut random_bytes); let id = Builder::from_random_bytes(random_bytes).into_uuid(); @@ -171,25 +119,3 @@ pub fn gen_uuid() -> Uuid { } pub const MAX_OP_ID: &str = "9223372036854775807"; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn quote_identifier_test() { - assert_eq!(quote_identifier("test"), "\"test\""); - assert_eq!(quote_identifier("\"quote\""), "\"\"\"quote\"\"\""); - assert_eq!( - quote_identifier("other characters."), - "\"other characters.\"" - ); - } - - #[test] - fn quote_string_test() { - assert_eq!(quote_string("test"), "'test'"); - assert_eq!(quote_string("\"quote\""), "'\"quote\"'"); - assert_eq!(quote_string("'quote'"), "'''quote'''"); - } -} diff --git a/crates/core/src/utils/sql_buffer.rs b/crates/core/src/utils/sql_buffer.rs new file mode 100644 index 00000000..3907a79f --- /dev/null +++ b/crates/core/src/utils/sql_buffer.rs @@ -0,0 +1,296 @@ +use core::fmt::{Display, Write}; + +use alloc::string::String; + +const DOUBLE_QUOTE: char = '"'; +const SINGLE_QUOTE: char = '\''; + +#[derive(Default)] +pub struct SqlBuffer { + pub sql: String, +} + +impl SqlBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn push_str(&mut self, str: &str) { + self.sql.push_str(str); + } + + pub fn push_char(&mut self, char: char) { + self.sql.push(char); + } + + pub fn comma(&mut self) { + self.push_str(", "); + } + + pub fn comma_separated<'a>(&'a mut self) -> CommaSeparated<'a> { + CommaSeparated::new(self) + } + + /// Creates a writer wrapped in double quotes for SQL identifiers. + pub fn identifier<'a>(&'a mut self) -> impl Write + 'a { + EscapingWriter::<'a, DOUBLE_QUOTE>::new(self) + } + + /// Creates a writer wrapped in single quotes for SQL strings. + pub fn string_literal<'a>(&'a mut self) -> impl Write + 'a { + EscapingWriter::<'a, SINGLE_QUOTE>::new(self) + } + + pub fn quote_internal_name(&mut self, name: &str, local_only: bool) { + self.quote_identifier_prefixed( + if local_only { + "ps_data_local__" + } else { + "ps_data__" + }, + name, + ); + } + + pub fn quote_identifier_prefixed(&mut self, prefix: &str, name: &str) { + let mut id = self.identifier(); + let _ = write!(id, "{prefix}{name}"); + } + + pub fn quote_json_path(&mut self, s: &str) { + let mut str = self.string_literal(); + let _ = write!(str, "$.{s}"); + } + + pub fn create_trigger(&mut self, prefix: &str, view_name: &str) { + self.push_str("CREATE TRIGGER "); + self.quote_identifier_prefixed(prefix, view_name); + self.push_char(' '); + } + + /// Writes an `INSTEAD OF $write_type ON $on FOR EACH ROW` segment. + pub fn trigger_instead_of(&mut self, write_type: &str, on: &str) { + self.push_str("INSTEAD OF "); + self.push_str(write_type); + self.push_str(" ON "); + let _ = self.identifier().write_str(on); + self.push_str(" FOR EACH ROW "); + } + + pub fn trigger_end(&mut self) { + self.push_str("END"); + } + + /// Writes a select statement throwing in triggers if `OLD.id != NEW.id`. + pub fn check_id_not_changed(&mut self) { + self.push_str( + "SELECT CASE WHEN (OLD.id != NEW.id) THEN RAISE (FAIL, 'Cannot update id') END;\n", + ); + } + + /// Writes a select statement throwing in triggers if `NEW.id` is null or not a string. + pub fn check_id_valid(&mut self) { + self.push_str( + "SELECT CASE WHEN (NEW.id IS NULL) THEN RAISE (FAIL, 'id is required') WHEN (typeof(NEW.id) != 'text') THEN RAISE (FAIL, 'id should be text') END;\n", + ); + } + + /// Writes an `INSERT INTO powersync_crud` statement. + pub fn insert_into_powersync_crud( + &mut self, + insert: InsertIntoCrud, + ) where + Id: Display, + Data: Display, + Old: Display, + Metadata: Display, + { + self.push_str("INSERT INTO powersync_crud(op,id,type"); + if insert.data.is_some() { + self.push_str(",data"); + } + if insert.old_values.is_some() { + self.push_str(",old_values"); + } + if insert.metadata.is_some() { + self.push_str(",metadata"); + } + if insert.options.is_some() { + self.push_str(",options"); + } + self.push_str(") VALUES ("); + + let _ = self.string_literal().write_str(insert.op); + self.comma(); + + let _ = write!(self, "{}", insert.id_expr); + self.comma(); + + let _ = self.string_literal().write_str(insert.type_name); + + if let Some(data) = insert.data { + self.comma(); + let _ = write!(self, "{}", data); + } + + if let Some(old) = insert.old_values { + self.comma(); + let _ = write!(self, "{}", old); + } + + if let Some(meta) = insert.metadata { + self.comma(); + let _ = write!(self, "{}", meta); + } + + if let Some(options) = insert.options { + self.comma(); + let _ = write!(self, "{}", options); + } + + self.push_str(");\n"); + } + + /// Generates a `CAST(json_extract(, "$.") as )` + pub fn json_extract_and_cast(&mut self, source: &str, name: &str, cast_to: &str) { + let _ = write!(self, "CAST(json_extract({source}, "); + self.quote_json_path(name); + self.push_str(") as "); + self.push_str(cast_to); + self.push_char(')'); + } + + /// Utility to write `inner` as an SQL identifier. + pub fn quote_identifier(inner: impl Display) -> String { + let mut buffer = SqlBuffer::new(); + let _ = write!(buffer.identifier(), "{}", inner); + buffer.sql + } +} + +impl Write for SqlBuffer { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + self.sql.write_str(s) + } + + fn write_char(&mut self, c: char) -> core::fmt::Result { + self.sql.write_char(c) + } +} + +/// A [Write] wrapper escaping identifiers or strings. +struct EscapingWriter<'a, const DELIMITER: char> { + buffer: &'a mut SqlBuffer, +} + +impl<'a, const DELIMITER: char> EscapingWriter<'a, DELIMITER> { + pub fn new(buffer: &'a mut SqlBuffer) -> Self { + let mut escaped = Self { buffer }; + escaped.write_delimiter(); + escaped + } + + fn write_delimiter(&mut self) { + self.buffer.sql.push(DELIMITER); + } + + fn write_escape_sequence(&mut self) { + self.write_delimiter(); + self.write_delimiter(); + } +} + +impl<'a, const DELIMITER: char> Write for EscapingWriter<'a, DELIMITER> { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + for (i, component) in s.split(DELIMITER).enumerate() { + if i != 0 { + self.write_escape_sequence(); + } + self.buffer.sql.push_str(component); + } + + Ok(()) + } +} + +impl Drop for EscapingWriter<'_, DELIMITER> { + fn drop(&mut self) { + self.write_delimiter(); + } +} + +pub struct CommaSeparated<'a> { + buffer: &'a mut SqlBuffer, + is_first: bool, +} + +impl<'a> CommaSeparated<'a> { + fn new(buffer: &'a mut SqlBuffer) -> Self { + Self { + buffer, + is_first: true, + } + } + + pub fn element(&mut self) -> &mut SqlBuffer { + if !self.is_first { + self.buffer.comma(); + } + + self.is_first = false; + self.buffer + } +} + +pub struct InsertIntoCrud<'a, Id, Data, Old, Metadata> +where + Id: Display, + Data: Display, + Old: Display, + Metadata: Display, +{ + pub op: &'a str, + pub id_expr: Id, + pub type_name: &'a str, + pub data: Option, + pub old_values: Option, + pub metadata: Option, + pub options: Option, +} + +#[cfg(test)] +mod test { + use super::SqlBuffer; + use core::fmt::{Display, Write}; + + #[test] + fn identifier() { + fn check_identifier(element: T, expected: &str) { + let mut buffer = SqlBuffer::default(); + let mut id = buffer.identifier(); + write!(&mut id, "{}", element).unwrap(); + drop(id); + + assert_eq!(buffer.sql, expected) + } + + check_identifier("foo", "\"foo\""); + check_identifier("foo\"bar", "\"foo\"\"bar\""); + } + + #[test] + fn string() { + fn check_string(element: T, expected: &str) { + let mut buffer = SqlBuffer::default(); + let mut id = buffer.string_literal(); + write!(&mut id, "{}", element).unwrap(); + drop(id); + + assert_eq!(buffer.sql, expected) + } + + check_string("foo", "'foo'"); + check_string("foo'bar", "'foo''bar'"); + check_string("foo'", "'foo'''"); + } +} diff --git a/crates/core/src/uuid.rs b/crates/core/src/uuid.rs index 423ec076..3a84b69c 100644 --- a/crates/core/src/uuid.rs +++ b/crates/core/src/uuid.rs @@ -10,7 +10,7 @@ use sqlite::ResultCode; use crate::create_sqlite_text_fn; use crate::error::PowerSyncError; -use crate::util::*; +use crate::utils::gen_uuid; fn uuid_v4_impl( _ctx: *mut sqlite::context, diff --git a/crates/core/src/view_admin.rs b/crates/core/src/view_admin.rs index 69295df3..a1850633 100644 --- a/crates/core/src/view_admin.rs +++ b/crates/core/src/view_admin.rs @@ -14,7 +14,7 @@ use crate::error::PowerSyncError; use crate::migrations::{LATEST_VERSION, powersync_migrate}; use crate::schema::inspection::ExistingView; use crate::state::DatabaseState; -use crate::util::quote_identifier; +use crate::utils::SqlBuffer; use crate::{create_auto_tx_function, create_sqlite_text_fn}; // Used in old down migrations, do not remove. @@ -109,7 +109,7 @@ DELETE FROM ps_stream_subscriptions; } for name in tables { - let quoted = quote_identifier(&name); + let quoted = SqlBuffer::quote_identifier(&name); // The first delete statement deletes a single row, to trigger an update notification for the table. // The second delete statement uses the truncate optimization to delete the remainder of the data. let delete_sql = format!( diff --git a/crates/core/src/views.rs b/crates/core/src/views.rs index 909feb91..11de20c9 100644 --- a/crates/core/src/views.rs +++ b/crates/core/src/views.rs @@ -1,14 +1,14 @@ extern crate alloc; use alloc::borrow::Cow; -use alloc::format; use alloc::string::String; -use alloc::vec::Vec; -use core::fmt::Write; +use alloc::{format, vec}; +use core::fmt::{Write, from_fn}; +use core::mem; use crate::error::PowerSyncError; use crate::schema::{Column, DiffIncludeOld, Table}; -use crate::util::*; +use crate::utils::{InsertIntoCrud, SqlBuffer}; pub fn powersync_view_sql(table_info: &Table) -> String { let name = &table_info.name; @@ -16,291 +16,273 @@ pub fn powersync_view_sql(table_info: &Table) -> String { let local_only = table_info.flags.local_only(); let include_metadata = table_info.flags.include_metadata(); - let quoted_name = quote_identifier(view_name); - let internal_name = quote_internal_name(name, local_only); - - let mut column_names_quoted: Vec = alloc::vec![]; - let mut column_values: Vec = alloc::vec![]; - column_names_quoted.push(quote_identifier("id")); - column_values.push(String::from("id")); - for column in &table_info.columns { - column_names_quoted.push(quote_identifier(&column.name)); - - column_values.push(format!( - "CAST(json_extract(data, {:}) as {:})", - quote_json_path(&column.name), - &column.type_name - )); + let mut sql = SqlBuffer::new(); + sql.push_str("CREATE VIEW "); + let _ = sql.identifier().write_str(view_name); + sql.push_char('('); + { + let mut sql = sql.comma_separated(); + let _ = sql.element().identifier().write_str("id"); + + for column in &table_info.columns { + let _ = sql.element().identifier().write_str(&column.name); + } + + if include_metadata { + let _ = sql.element().identifier().write_str("_metadata"); + let _ = sql.element().identifier().write_str("_deleted"); + } } - if include_metadata { - column_names_quoted.push(quote_identifier("_metadata")); - column_values.push(String::from("NULL")); + sql.push_str(") AS SELECT "); + { + let mut sql = sql.comma_separated(); + sql.element().push_str("id"); + + for column in &table_info.columns { + let sql = sql.element(); + + sql.json_extract_and_cast("data", &column.name, &column.type_name); + } - column_names_quoted.push(quote_identifier("_deleted")); - column_values.push(String::from("NULL")); + if include_metadata { + // For _metadata and _deleted columns + sql.element().push_str("NULL"); + sql.element().push_str("NULL"); + } } - let view_statement = format!( - "CREATE VIEW {:}({:}) AS SELECT {:} FROM {:} -- powersync-auto-generated", - quoted_name, - column_names_quoted.join(", "), - column_values.join(", "), - internal_name - ); + sql.push_str(" FROM "); + sql.quote_internal_name(name, local_only); + sql.push_str(" -- powersync-auto-generated"); - return view_statement; + return sql.sql; } pub fn powersync_trigger_delete_sql(table_info: &Table) -> Result { + if table_info.flags.insert_only() { + // Insert-only tables have no DELETE triggers + return Ok(String::new()); + } + let name = &table_info.name; - let view_name = &table_info.view_name(); + let view_name = table_info.view_name(); let local_only = table_info.flags.local_only(); - let insert_only = table_info.flags.insert_only(); - let quoted_name = quote_identifier(view_name); - let internal_name = quote_internal_name(name, local_only); - let trigger_name = quote_identifier_prefixed("ps_view_delete_", view_name); - let type_string = quote_string(name); - - let (old_data_name, old_data_value): (&'static str, Cow<'static, str>) = - match &table_info.diff_include_old { - Some(include_old) => { - let mut json = match include_old { - DiffIncludeOld::OnlyForColumns { columns } => json_object_fragment( - "OLD", - &mut table_info.filtered_columns(columns.iter().map(|c| c.as_str())), - ), - DiffIncludeOld::ForAllColumns => { - json_object_fragment("OLD", &mut table_info.columns.iter()) - } - }?; - - json.insert(0, ','); - (",old_values", json.into()) - } - None => ("", "".into()), - }; + let mut sql = SqlBuffer::new(); + sql.create_trigger("ps_view_delete_", view_name); + sql.trigger_instead_of("DELETE", view_name); + sql.push_str("BEGIN\n"); + // First, forward to internal data table. + sql.push_str("DELETE FROM "); + sql.quote_internal_name(name, local_only); + sql.push_str(" WHERE id = OLD.id;\n"); + + let old_data_value = match &table_info.diff_include_old { + Some(include_old) => { + let json = match include_old { + DiffIncludeOld::OnlyForColumns { columns } => json_object_fragment( + "OLD", + &mut table_info.filtered_columns(columns.iter().map(|c| c.as_str())), + ), + DiffIncludeOld::ForAllColumns => { + json_object_fragment("OLD", &mut table_info.columns.iter()) + } + }?; + + Some(json) + } + None => None, + }; - return if !local_only && !insert_only { - let mut trigger = format!( - "\ -CREATE TRIGGER {trigger_name} -INSTEAD OF DELETE ON {quoted_name} -FOR EACH ROW -BEGIN -DELETE FROM {internal_name} WHERE id = OLD.id; -INSERT INTO powersync_crud(op,id,type{old_data_name}) VALUES ('DELETE',OLD.id,{type_string}{old_data_value}); -END" - ); + if !local_only { + // We also need to record the write in powersync_crud. + sql.insert_into_powersync_crud(InsertIntoCrud { + op: "DELETE", + id_expr: "OLD.id", + type_name: name, + data: None::<&'static str>, + old_values: old_data_value.as_ref(), + metadata: None::<&'static str>, + options: None, + }); - // The DELETE statement can't include metadata for the delete operation, so we create - // another trigger to delete with a fake UPDATE syntax. if table_info.flags.include_metadata() { - let trigger_name = quote_identifier_prefixed("ps_view_delete2_", view_name); - write!(&mut trigger, "\ -; -CREATE TRIGGER {trigger_name} -INSTEAD OF UPDATE ON {quoted_name} -FOR EACH ROW -WHEN NEW._deleted IS TRUE -BEGIN -DELETE FROM {internal_name} WHERE id = NEW.id; -INSERT INTO powersync_crud(op,id,type,metadata{old_data_name}) VALUES ('DELETE',OLD.id,{type_string},NEW._metadata{old_data_value}); -END" - ).expect("writing to string should be infallible"); + // The DELETE statement can't include metadata for the delete operation, so we create + // another trigger to delete with a fake UPDATE syntax. + sql.trigger_end(); + sql.push_str(";\n"); + + sql.create_trigger("ps_view_delete2_", view_name); + sql.trigger_instead_of("UPDATE", view_name); + sql.push_str("WHEN NEW._deleted IS TRUE BEGIN DELETE FROM "); + sql.quote_internal_name(name, local_only); + sql.push_str(" WHERE id = OLD.id; "); + + sql.insert_into_powersync_crud(InsertIntoCrud { + op: "DELETE", + id_expr: "OLD.id", + type_name: name, + data: None::<&'static str>, + old_values: old_data_value.as_ref(), + metadata: Some("NEW._metadata"), + options: None, + }); } + } - Ok(trigger) - } else if local_only { - debug_assert!(!table_info.flags.include_metadata()); - - let trigger = format!( - "\ -CREATE TRIGGER {trigger_name} -INSTEAD OF DELETE ON {quoted_name} -FOR EACH ROW -BEGIN -DELETE FROM {internal_name} WHERE id = OLD.id; -END", - ); - Ok(trigger) - } else if insert_only { - Ok(String::from("")) - } else { - Err(PowerSyncError::argument_error("invalid flags for table")) - }; + sql.trigger_end(); + return Ok(sql.sql); } pub fn powersync_trigger_insert_sql(table_info: &Table) -> Result { let name = &table_info.name; - let view_name = &table_info.view_name(); + let view_name = table_info.view_name(); let local_only = table_info.flags.local_only(); let insert_only = table_info.flags.insert_only(); - let quoted_name = quote_identifier(view_name); - let internal_name = quote_internal_name(name, local_only); - let trigger_name = quote_identifier_prefixed("ps_view_insert_", view_name); - let type_string = quote_string(name); + let mut sql = SqlBuffer::new(); + sql.create_trigger("ps_view_insert_", view_name); + sql.trigger_instead_of("INSERT", view_name); + sql.push_str("BEGIN\n"); - let json_fragment = json_object_fragment("NEW", &mut table_info.columns.iter())?; + if !local_only { + sql.check_id_valid(); + } - let (metadata_key, metadata_value) = if table_info.flags.include_metadata() { - (",metadata", ",NEW._metadata") - } else { - ("", "") - }; + let json_fragment = json_object_fragment("NEW", &mut table_info.columns.iter())?; - return if !local_only && !insert_only { - let trigger = format!("\ - CREATE TRIGGER {trigger_name} - INSTEAD OF INSERT ON {quoted_name} - FOR EACH ROW - BEGIN - SELECT CASE - WHEN (NEW.id IS NULL) - THEN RAISE (FAIL, 'id is required') - WHEN (typeof(NEW.id) != 'text') - THEN RAISE (FAIL, 'id should be text') - END; - INSERT INTO {internal_name} SELECT NEW.id, {json_fragment}; - INSERT INTO powersync_crud(op,id,type,data{metadata_key}) VALUES ('PUT',NEW.id,{type_string},json(powersync_diff('{{}}', {:})){metadata_value}); - END", json_fragment); - Ok(trigger) - } else if local_only { - let trigger = format!( - "\ - CREATE TRIGGER {trigger_name} - INSTEAD OF INSERT ON {quoted_name} - FOR EACH ROW - BEGIN - INSERT INTO {internal_name} SELECT NEW.id, {json_fragment}; - END", - ); - Ok(trigger) - } else if insert_only { + if insert_only { // This is using the manual powersync_crud_ instead of powersync_crud because insert-only // writes shouldn't prevent us from receiving new data. - let trigger = format!("\ - CREATE TRIGGER {trigger_name} - INSTEAD OF INSERT ON {quoted_name} - FOR EACH ROW - BEGIN - INSERT INTO powersync_crud_(data) VALUES(json_object('op', 'PUT', 'type', {}, 'id', NEW.id, 'data', json(powersync_diff('{{}}', {:})))); - END", type_string, json_fragment); - Ok(trigger) + sql.push_str("INSERT INTO powersync_crud_(data) VALUES(json_object('op', 'PUT', 'type', "); + let _ = sql.string_literal().write_str(name); + + let _ = write!( + &mut sql, + ", 'id', NEW.id, 'data', json(powersync_diff('{{}}', {:}))));", + json_fragment, + ); } else { - Err(PowerSyncError::argument_error("invalid flags for table")) - }; + // Insert into the underlying data table. + sql.push_str("INSERT INTO "); + sql.quote_internal_name(name, local_only); + let _ = write!(&mut sql, " SELECT NEW.id, {json_fragment};\n"); + + if !local_only { + // Record write into powersync_crud + sql.insert_into_powersync_crud(InsertIntoCrud { + op: "PUT", + id_expr: "NEW.id", + type_name: name, + data: Some(from_fn(|f| { + write!(f, "json(powersync_diff('{{}}', {:}))", json_fragment) + })), + old_values: None::<&'static str>, + metadata: if table_info.flags.include_metadata() { + Some("NEW._metadata") + } else { + None + }, + options: None, + }); + } + } + + sql.trigger_end(); + Ok(sql.sql) } pub fn powersync_trigger_update_sql(table_info: &Table) -> Result { + if table_info.flags.insert_only() { + // Insert-only tables have no UPDATE triggers + return Ok(String::new()); + } + let name = &table_info.name; - let view_name = &table_info.view_name(); - let insert_only = table_info.flags.insert_only(); + let view_name = table_info.view_name(); let local_only = table_info.flags.local_only(); - let quoted_name = quote_identifier(view_name); - let internal_name = quote_internal_name(name, local_only); - let trigger_name = quote_identifier_prefixed("ps_view_update_", view_name); - let type_string = quote_string(name); + let mut sql = SqlBuffer::new(); + sql.create_trigger("ps_view_update_", view_name); + sql.trigger_instead_of("UPDATE", view_name); + + // If we're supposed to include metadata, we support UPDATE ... SET _deleted = TRUE with + // another trigger (because there's no way to attach data to DELETE statements otherwise). + if table_info.flags.include_metadata() { + sql.push_str(" WHEN NEW._deleted IS NOT TRUE "); + } + sql.push_str("BEGIN\n"); + sql.check_id_not_changed(); let json_fragment_new = json_object_fragment("NEW", &mut table_info.columns.iter())?; let json_fragment_old = json_object_fragment("OLD", &mut table_info.columns.iter())?; - let mut old_values_fragment = match &table_info.diff_include_old { - None => None, - Some(DiffIncludeOld::ForAllColumns) => Some(json_fragment_old.clone()), - Some(DiffIncludeOld::OnlyForColumns { columns }) => Some(json_object_fragment( - "OLD", - &mut table_info.filtered_columns(columns.iter().map(|c| c.as_str())), - )?), - }; + // UPDATE {internal_name} SET data = {json_fragment_new} WHERE id = NEW.id; + sql.push_str("UPDATE "); + sql.quote_internal_name(name, local_only); + let _ = write!( + &mut sql, + " SET data = {json_fragment_new} WHERE id = NEW.id;\n" + ); - if table_info.flags.include_old_only_when_changed() { - old_values_fragment = match old_values_fragment { + if !local_only { + let mut old_values_fragment = match &table_info.diff_include_old { None => None, - Some(f) => { - let filtered_new_fragment = match &table_info.diff_include_old { - // When include_old_only_when_changed is combined with a column filter, make sure we - // only include the powersync_diff of columns matched by the filter. - Some(DiffIncludeOld::OnlyForColumns { columns }) => { - Cow::Owned(json_object_fragment( - "NEW", - &mut table_info.filtered_columns(columns.iter().map(|c| c.as_str())), - )?) - } - _ => Cow::Borrowed(json_fragment_new.as_str()), - }; - - Some(format!( - "json(powersync_diff({filtered_new_fragment}, {f}))" - )) + Some(DiffIncludeOld::ForAllColumns) => Some(json_fragment_old.clone()), + Some(DiffIncludeOld::OnlyForColumns { columns }) => Some(json_object_fragment( + "OLD", + &mut table_info.filtered_columns(columns.iter().map(|c| c.as_str())), + )?), + }; + + if table_info.flags.include_old_only_when_changed() { + old_values_fragment = match old_values_fragment { + None => None, + Some(f) => { + let filtered_new_fragment = match &table_info.diff_include_old { + // When include_old_only_when_changed is combined with a column filter, make sure we + // only include the powersync_diff of columns matched by the filter. + Some(DiffIncludeOld::OnlyForColumns { columns }) => { + Cow::Owned(json_object_fragment( + "NEW", + &mut table_info + .filtered_columns(columns.iter().map(|c| c.as_str())), + )?) + } + _ => Cow::Borrowed(json_fragment_new.as_str()), + }; + + Some(format!( + "json(powersync_diff({filtered_new_fragment}, {f}))" + )) + } } } - } - - let (old_key, old_value): (&'static str, Cow<'static, str>) = match old_values_fragment { - Some(f) => (",old_values", format!(",{f}").into()), - None => ("", "".into()), - }; - let (metadata_key, metadata_value) = if table_info.flags.include_metadata() { - (",metadata", ",NEW._metadata") - } else { - ("", "") - }; - - return if !local_only && !insert_only { - // If we're supposed to include metadata, we support UPDATE ... SET _deleted = TRUE with - // another trigger (because there's no way to attach data to DELETE statements otherwise). - let when = if table_info.flags.include_metadata() { - " WHEN NEW._deleted IS NOT TRUE" - } else { - "" - }; + // Also forward write to powersync_crud vtab. + sql.insert_into_powersync_crud(InsertIntoCrud { + op: "PATCH", + id_expr: "NEW.id", + type_name: name, + data: Some(from_fn(|f| { + write!( + f, + "json(powersync_diff({json_fragment_old}, {json_fragment_new}))" + ) + })), + old_values: old_values_fragment.as_ref(), + metadata: if table_info.flags.include_metadata() { + Some("NEW._metadata") + } else { + None + }, + options: Some(table_info.flags.0), + }); + } - let flags = table_info.flags.0; - - let trigger = format!("\ -CREATE TRIGGER {trigger_name} -INSTEAD OF UPDATE ON {quoted_name} -FOR EACH ROW{when} -BEGIN - SELECT CASE - WHEN (OLD.id != NEW.id) - THEN RAISE (FAIL, 'Cannot update id') - END; - UPDATE {internal_name} - SET data = {json_fragment_new} - WHERE id = NEW.id; - INSERT INTO powersync_crud(op,type,id,data,options{old_key}{metadata_key}) VALUES ('PATCH',{type_string},NEW.id,json(powersync_diff({:}, {:})),{flags}{old_value}{metadata_value}); -END", json_fragment_old, json_fragment_new); - Ok(trigger) - } else if local_only { - debug_assert!(!table_info.flags.include_metadata()); - - let trigger = format!( - "\ -CREATE TRIGGER {trigger_name} -INSTEAD OF UPDATE ON {quoted_name} -FOR EACH ROW -BEGIN - SELECT CASE - WHEN (OLD.id != NEW.id) - THEN RAISE (FAIL, 'Cannot update id') - END; - UPDATE {internal_name} - SET data = {json_fragment_new} - WHERE id = NEW.id; -END" - ); - Ok(trigger) - } else if insert_only { - Ok(String::from("")) - } else { - Err(PowerSyncError::argument_error("invalid flags for table")) - }; + sql.trigger_end(); + Ok(sql.sql) } /// Given a query returning column names, return a JSON object fragment for a trigger. @@ -315,40 +297,171 @@ fn json_object_fragment<'a>( // and don't try to query the limit dynamically. const MAX_ARG_COUNT: usize = 50; - let mut column_names_quoted: Vec = alloc::vec![]; + let mut pending_json_object_invocations = vec![]; + let mut pending_json_object = None::<(usize, SqlBuffer)>; + let mut total_columns = 0usize; + + fn new_pending_object() -> (usize, SqlBuffer) { + let mut buffer = SqlBuffer::new(); + buffer.push_str("json_object("); + (0, buffer) + } + + fn build_pending_object(obj: &mut (usize, SqlBuffer)) -> String { + obj.1.push_char(')'); // close json_object( invocation + let (_, buffer) = mem::replace(obj, new_pending_object()); + buffer.sql + } + while let Some(column) = columns.next() { + total_columns += 1; + // SQLITE_MAX_COLUMN - 1 (because of the id column) + if total_columns > 1999 { + return Err(PowerSyncError::argument_error( + "too many parameters to json_object_fragment", + )); + } + let name = &*column.name; + let pending_object = pending_json_object.get_or_insert_with(new_pending_object); + if pending_object.0 == MAX_ARG_COUNT { + // We already have 50 key-value pairs in this call, finish. + pending_json_object_invocations.push(build_pending_object(pending_object)); + } + + let existing_elements = pending_object.0; + let sql = &mut pending_object.1; + + if pending_object.0 != 0 { + sql.comma(); + } + + // Append a "key", powersync_strip_subtype(prefix."KEY") pair to the json_each invocation. + let _ = sql.string_literal().write_str(name); // JSON object key + sql.comma(); + // We really want the individual columns here to appear as they show up in the database. // For text columns however, it's possible that e.g. NEW.column was created by a JSON // function, meaning that it has a JSON subtype active - causing the json_object() call // we're about to emit to include it as a subobject instead of a string. - column_names_quoted.push(format!( - "{:}, powersync_strip_subtype({:}.{:})", - QuotedString(name), - prefix, - quote_identifier(name) - )); + sql.push_str("powersync_strip_subtype("); + sql.push_str(prefix); + sql.push_char('.'); + let _ = sql.identifier().write_str(name); + sql.push_char(')'); + + pending_object.0 = existing_elements + 1; } - // SQLITE_MAX_COLUMN - 1 (because of the id column) - if column_names_quoted.len() > 1999 { - return Err(PowerSyncError::argument_error( - "too many parameters to json_object_fragment", - )); - } else if column_names_quoted.len() <= MAX_ARG_COUNT { - // Small number of columns - use json_object() directly. - let json_fragment = column_names_quoted.join(", "); - return Ok(format!("json_object({:})", json_fragment)); + if pending_json_object_invocations.is_empty() { + // Not exceeding 50 elements, return single json_object invocation. + Ok(build_pending_object( + pending_json_object.get_or_insert_with(new_pending_object), + )) } else { - // Too many columns to use json_object directly. - // Instead, we build up the JSON object in chunks, - // and merge using powersync_json_merge(). - let mut fragments: Vec = alloc::vec![]; - for chunk in column_names_quoted.chunks(MAX_ARG_COUNT) { - let sub_fragment = chunk.join(", "); - fragments.push(format!("json_object({:})", sub_fragment)); + // Too many columns to use json_object directly. Instead, we build up the JSON object in + // chunks, and merge using powersync_json_merge(). + if let Some(pending) = &mut pending_json_object { + pending_json_object_invocations.push(build_pending_object(pending)); } - return Ok(format!("powersync_json_merge({:})", fragments.join(", "))); + + let mut sql = SqlBuffer::new(); + sql.push_str("powersync_json_merge("); + let mut comma_separated = sql.comma_separated(); + for fragment in pending_json_object_invocations { + let _ = comma_separated.element().push_str(&fragment); + } + sql.push_char(')'); + Ok(sql.sql) + } +} + +#[cfg(test)] +mod test { + use alloc::{string::ToString, vec}; + + use crate::{ + schema::{Column, Table, TableInfoFlags}, + views::{ + json_object_fragment, powersync_trigger_delete_sql, powersync_trigger_insert_sql, + powersync_trigger_update_sql, powersync_view_sql, + }, + }; + + fn test_table() -> Table { + return Table { + name: "table".to_string(), + view_name_override: None, + columns: vec![ + Column { + name: "a".to_string(), + type_name: "text".to_string(), + }, + Column { + name: "b".to_string(), + type_name: "integer".to_string(), + }, + ], + indexes: vec![], + diff_include_old: None, + flags: TableInfoFlags::default(), + }; + } + + #[test] + fn test_json_object_fragment() { + let fragment = + json_object_fragment("NEW", &mut test_table().columns.iter()).expect("should generate"); + + assert_eq!( + fragment, + r#"json_object('a', powersync_strip_subtype(NEW."a"), 'b', powersync_strip_subtype(NEW."b"))"# + ); + } + + #[test] + fn test_view() { + let stmt = powersync_view_sql(&test_table()); + + assert_eq!( + stmt, + r#"CREATE VIEW "table"("id", "a", "b") AS SELECT id, CAST(json_extract(data, '$.a') as text), CAST(json_extract(data, '$.b') as integer) FROM "ps_data__table" -- powersync-auto-generated"# + ); + } + + #[test] + fn test_delete_trigger() { + let stmt = powersync_trigger_delete_sql(&test_table()).expect("should generate"); + + assert_eq!( + stmt, + r#"CREATE TRIGGER "ps_view_delete_table" INSTEAD OF DELETE ON "table" FOR EACH ROW BEGIN +DELETE FROM "ps_data__table" WHERE id = OLD.id; +INSERT INTO powersync_crud(op,id,type) VALUES ('DELETE', OLD.id, 'table'); +END"# + ); + } + + #[test] + fn local_only_does_not_write_into_ps_crud() { + let mut table = test_table(); + table.flags.0 = 1; // local-only bit + + assert!( + !powersync_trigger_insert_sql(&table) + .unwrap() + .contains("powersync_crud") + ); + assert!( + !powersync_trigger_update_sql(&table) + .unwrap() + .contains("powersync_crud") + ); + assert!( + !powersync_trigger_delete_sql(&table) + .unwrap() + .contains("powersync_crud") + ); } } diff --git a/dart/test/crud_test.dart b/dart/test/crud_test.dart index 0f1c90e1..834301ff 100644 --- a/dart/test/crud_test.dart +++ b/dart/test/crud_test.dart @@ -710,5 +710,69 @@ void main() { containsPair('data', {'col': 'not an integer'}), ]); }); + + group('insert only', () { + test('smoke test', () { + db + ..execute('select powersync_replace_schema(?)', [ + json.encode({ + 'tables': [ + { + 'name': 'items', + 'insert_only': true, + 'columns': [ + {'name': 'col', 'type': 'int'} + ], + } + ] + }) + ]) + ..execute( + 'INSERT INTO items (id, col) VALUES (uuid(), 1)', + ); + + expect(db.select('SELECT * FROM ps_crud'), hasLength(1)); + // Insert-only tables don't update the $local bucket + expect(db.select('SELECT * FROM ps_buckets'), isEmpty); + + // Can't update or delete insert-only tables. + expect(() => db.execute('UPDATE items SET col = col + 1'), + throwsA(anything)); + expect(() => db.execute('DELETE FROM items WHERE col = 1'), + throwsA(anything)); + }); + + test('has no effect on local-only tables', () { + db + ..execute('select powersync_replace_schema(?)', [ + json.encode({ + 'tables': [ + { + 'name': 'items', + 'insert_only': true, + 'local_only': true, + 'columns': [ + {'name': 'col', 'type': 'int'} + ], + } + ] + }) + ]); + + db.execute( + 'INSERT INTO items (id, col) VALUES (uuid(), 1)', + ); + expect(db.select('SELECT * FROM items'), hasLength(1)); + + db + ..execute('UPDATE items SET col = col + 1') + ..execute('DELETE FROM items WHERE col = 2'); + expect(db.select('SELECT * FROM items'), isEmpty); + + // because this is a local-only table, no crud items should have been + // created. + expect(db.select('SELECT * FROM ps_crud'), isEmpty); + }); + }); }); } diff --git a/dart/test/utils/migration_fixtures.dart b/dart/test/utils/migration_fixtures.dart index 601d2b17..09d67be9 100644 --- a/dart/test/utils/migration_fixtures.dart +++ b/dart/test/utils/migration_fixtures.dart @@ -726,75 +726,37 @@ const schemaDown3 = r''' const schema5 = r''' ;CREATE TABLE "ps_data__lists"(id TEXT PRIMARY KEY NOT NULL, data TEXT) ;CREATE VIEW "lists"("id", "description") AS SELECT id, CAST(json_extract(data, '$.description') as TEXT) FROM "ps_data__lists" -- powersync-auto-generated -;CREATE TRIGGER "ps_view_delete_lists" -INSTEAD OF DELETE ON "lists" -FOR EACH ROW -BEGIN +;CREATE TRIGGER "ps_view_delete_lists" INSTEAD OF DELETE ON "lists" FOR EACH ROW BEGIN DELETE FROM "ps_data__lists" WHERE id = OLD.id; -INSERT INTO powersync_crud(op,id,type) VALUES ('DELETE',OLD.id,'lists'); +INSERT INTO powersync_crud(op,id,type) VALUES ('DELETE', OLD.id, 'lists'); END -;CREATE TRIGGER "ps_view_insert_lists" - INSTEAD OF INSERT ON "lists" - FOR EACH ROW - BEGIN - SELECT CASE - WHEN (NEW.id IS NULL) - THEN RAISE (FAIL, 'id is required') - WHEN (typeof(NEW.id) != 'text') - THEN RAISE (FAIL, 'id should be text') - END; - INSERT INTO "ps_data__lists" SELECT NEW.id, json_object('description', powersync_strip_subtype(NEW."description")); - INSERT INTO powersync_crud(op,id,type,data) VALUES ('PUT',NEW.id,'lists',json(powersync_diff('{}', json_object('description', powersync_strip_subtype(NEW."description"))))); - END -;CREATE TRIGGER "ps_view_update_lists" -INSTEAD OF UPDATE ON "lists" -FOR EACH ROW -BEGIN - SELECT CASE - WHEN (OLD.id != NEW.id) - THEN RAISE (FAIL, 'Cannot update id') - END; - UPDATE "ps_data__lists" - SET data = json_object('description', powersync_strip_subtype(NEW."description")) - WHERE id = NEW.id; - INSERT INTO powersync_crud(op,type,id,data,options) VALUES ('PATCH','lists',NEW.id,json(powersync_diff(json_object('description', powersync_strip_subtype(OLD."description")), json_object('description', powersync_strip_subtype(NEW."description")))),0); +;CREATE TRIGGER "ps_view_insert_lists" INSTEAD OF INSERT ON "lists" FOR EACH ROW BEGIN +SELECT CASE WHEN (NEW.id IS NULL) THEN RAISE (FAIL, 'id is required') WHEN (typeof(NEW.id) != 'text') THEN RAISE (FAIL, 'id should be text') END; +INSERT INTO "ps_data__lists" SELECT NEW.id, json_object('description', powersync_strip_subtype(NEW."description")); +INSERT INTO powersync_crud(op,id,type,data) VALUES ('PUT', NEW.id, 'lists', json(powersync_diff('{}', json_object('description', powersync_strip_subtype(NEW."description"))))); +END +;CREATE TRIGGER "ps_view_update_lists" INSTEAD OF UPDATE ON "lists" FOR EACH ROW BEGIN +SELECT CASE WHEN (OLD.id != NEW.id) THEN RAISE (FAIL, 'Cannot update id') END; +UPDATE "ps_data__lists" SET data = json_object('description', powersync_strip_subtype(NEW."description")) WHERE id = NEW.id; +INSERT INTO powersync_crud(op,id,type,data,options) VALUES ('PATCH', NEW.id, 'lists', json(powersync_diff(json_object('description', powersync_strip_subtype(OLD."description")), json_object('description', powersync_strip_subtype(NEW."description")))), 0); END '''; const currentDeveloperSchema = r''' ;CREATE TABLE "ps_data__lists"(id TEXT PRIMARY KEY NOT NULL, data TEXT) ;CREATE VIEW "lists"("id", "description") AS SELECT id, CAST(json_extract(data, '$.description') as TEXT) FROM "ps_data__lists" -- powersync-auto-generated -;CREATE TRIGGER "ps_view_delete_lists" -INSTEAD OF DELETE ON "lists" -FOR EACH ROW -BEGIN +;CREATE TRIGGER "ps_view_delete_lists" INSTEAD OF DELETE ON "lists" FOR EACH ROW BEGIN DELETE FROM "ps_data__lists" WHERE id = OLD.id; -INSERT INTO powersync_crud(op,id,type) VALUES ('DELETE',OLD.id,'lists'); +INSERT INTO powersync_crud(op,id,type) VALUES ('DELETE', OLD.id, 'lists'); END -;CREATE TRIGGER "ps_view_insert_lists" - INSTEAD OF INSERT ON "lists" - FOR EACH ROW - BEGIN - SELECT CASE - WHEN (NEW.id IS NULL) - THEN RAISE (FAIL, 'id is required') - WHEN (typeof(NEW.id) != 'text') - THEN RAISE (FAIL, 'id should be text') - END; - INSERT INTO "ps_data__lists" SELECT NEW.id, json_object('description', powersync_strip_subtype(NEW."description")); - INSERT INTO powersync_crud(op,id,type,data) VALUES ('PUT',NEW.id,'lists',json(powersync_diff('{}', json_object('description', powersync_strip_subtype(NEW."description"))))); - END -;CREATE TRIGGER "ps_view_update_lists" -INSTEAD OF UPDATE ON "lists" -FOR EACH ROW -BEGIN - SELECT CASE - WHEN (OLD.id != NEW.id) - THEN RAISE (FAIL, 'Cannot update id') - END; - UPDATE "ps_data__lists" - SET data = json_object('description', powersync_strip_subtype(NEW."description")) - WHERE id = NEW.id; - INSERT INTO powersync_crud(op,type,id,data,options) VALUES ('PATCH','lists',NEW.id,json(powersync_diff(json_object('description', powersync_strip_subtype(OLD."description")), json_object('description', powersync_strip_subtype(NEW."description")))),0); +;CREATE TRIGGER "ps_view_insert_lists" INSTEAD OF INSERT ON "lists" FOR EACH ROW BEGIN +SELECT CASE WHEN (NEW.id IS NULL) THEN RAISE (FAIL, 'id is required') WHEN (typeof(NEW.id) != 'text') THEN RAISE (FAIL, 'id should be text') END; +INSERT INTO "ps_data__lists" SELECT NEW.id, json_object('description', powersync_strip_subtype(NEW."description")); +INSERT INTO powersync_crud(op,id,type,data) VALUES ('PUT', NEW.id, 'lists', json(powersync_diff('{}', json_object('description', powersync_strip_subtype(NEW."description"))))); +END +;CREATE TRIGGER "ps_view_update_lists" INSTEAD OF UPDATE ON "lists" FOR EACH ROW BEGIN +SELECT CASE WHEN (OLD.id != NEW.id) THEN RAISE (FAIL, 'Cannot update id') END; +UPDATE "ps_data__lists" SET data = json_object('description', powersync_strip_subtype(NEW."description")) WHERE id = NEW.id; +INSERT INTO powersync_crud(op,id,type,data,options) VALUES ('PATCH', NEW.id, 'lists', json(powersync_diff(json_object('description', powersync_strip_subtype(OLD."description")), json_object('description', powersync_strip_subtype(NEW."description")))), 0); END '''; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index af7304e5..b8ce6f81 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "nightly-2025-10-31" +channel = "nightly-2025-12-05"