diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index bc13c971..84dd3d2a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -64,7 +64,6 @@ fn init_extension(db: *mut sqlite::sqlite3) -> Result<(), PowerSyncError> { let state = Rc::new(DatabaseState::new()); crate::version::register(db)?; - crate::views::register(db)?; crate::uuid::register(db)?; crate::diff::register(db)?; crate::fix_data::register(db)?; diff --git a/crates/core/src/migrations.rs b/crates/core/src/migrations.rs index 89f65e5c..56a05745 100644 --- a/crates/core/src/migrations.rs +++ b/crates/core/src/migrations.rs @@ -10,6 +10,7 @@ use sqlite::ResultCode; use crate::error::{PSResult, PowerSyncError}; use crate::fix_data::apply_v035_fix; +use crate::schema::inspection::ExistingView; use crate::sync::BucketPriority; pub const LATEST_VERSION: i32 = 11; @@ -188,15 +189,19 @@ VALUES(4, // Down migrations are less common, so we're okay about that breaking // in some cases. + for mut view in ExistingView::list(local_db)? { + view.delete_trigger_sql = String::default(); + view.update_trigger_sql = String::default(); + view.insert_trigger_sql = String::default(); + + // This drops everything, but immediately re-creates the CREATE VIEW statement. + view.create(local_db)?; + } + // language=SQLite local_db .exec_safe( "\ -UPDATE powersync_views SET - delete_trigger_sql = '', - update_trigger_sql = '', - insert_trigger_sql = ''; - ALTER TABLE ps_buckets RENAME TO ps_buckets_old; ALTER TABLE ps_oplog RENAME TO ps_oplog_old; diff --git a/crates/core/src/schema/inspection.rs b/crates/core/src/schema/inspection.rs new file mode 100644 index 00000000..3bd4c767 --- /dev/null +++ b/crates/core/src/schema/inspection.rs @@ -0,0 +1,81 @@ +use alloc::borrow::ToOwned; +use alloc::{format, vec}; +use alloc::{string::String, vec::Vec}; +use powersync_sqlite_nostd::Connection; +use powersync_sqlite_nostd::{self as sqlite, ResultCode}; + +use crate::error::{PSResult, PowerSyncError}; +use crate::util::quote_identifier; + +/// An existing PowerSync-managed view that was found in the schema. +#[derive(PartialEq)] +pub struct ExistingView { + /// The name of the view itself. + pub name: String, + /// SQL contents of the `CREATE VIEW` statement. + pub sql: String, + /// SQL contents of all triggers implementing deletes by forwarding to + /// `ps_data` and `ps_crud`. + pub delete_trigger_sql: String, + /// SQL contents of the trigger implementing inserts on this view. + pub insert_trigger_sql: String, + /// SQL contents of the trigger implementing updates on this view. + pub update_trigger_sql: String, +} + +impl ExistingView { + pub fn list(db: *mut sqlite::sqlite3) -> Result, PowerSyncError> { + let mut results = vec![]; + let stmt = db.prepare_v2(" +SELECT + view.name, + view.sql, + ifnull(group_concat(trigger1.sql, ';\n' ORDER BY trigger1.name DESC), ''), + ifnull(trigger2.sql, ''), + ifnull(trigger3.sql, '') + FROM sqlite_master view + LEFT JOIN sqlite_master trigger1 + ON trigger1.tbl_name = view.name AND trigger1.type = 'trigger' AND trigger1.name GLOB 'ps_view_delete*' + LEFT JOIN sqlite_master trigger2 + ON trigger2.tbl_name = view.name AND trigger2.type = 'trigger' AND trigger2.name GLOB 'ps_view_insert*' + LEFT JOIN sqlite_master trigger3 + ON trigger3.tbl_name = view.name AND trigger3.type = 'trigger' AND trigger3.name GLOB 'ps_view_update*' + WHERE view.type = 'view' AND view.sql GLOB '*-- powersync-auto-generated' + GROUP BY view.name; + ").into_db_result(db)?; + + while stmt.step()? == ResultCode::ROW { + let name = stmt.column_text(0)?.to_owned(); + let sql = stmt.column_text(1)?.to_owned(); + let delete = stmt.column_text(2)?.to_owned(); + let insert = stmt.column_text(3)?.to_owned(); + let update = stmt.column_text(4)?.to_owned(); + + results.push(ExistingView { + name, + sql, + delete_trigger_sql: delete, + insert_trigger_sql: insert, + update_trigger_sql: update, + }); + } + + Ok(results) + } + + pub fn drop_by_name(db: *mut sqlite::sqlite3, name: &str) -> Result<(), PowerSyncError> { + let q = format!("DROP VIEW IF EXISTS {:}", quote_identifier(name)); + db.exec_safe(&q)?; + Ok(()) + } + + pub fn create(&self, db: *mut sqlite::sqlite3) -> Result<(), PowerSyncError> { + Self::drop_by_name(db, &self.name)?; + db.exec_safe(&self.sql).into_db_result(db)?; + db.exec_safe(&self.delete_trigger_sql).into_db_result(db)?; + db.exec_safe(&self.insert_trigger_sql).into_db_result(db)?; + db.exec_safe(&self.update_trigger_sql).into_db_result(db)?; + + Ok(()) + } +} diff --git a/crates/core/src/schema/management.rs b/crates/core/src/schema/management.rs index a12b7e98..6ba25f14 100644 --- a/crates/core/src/schema/management.rs +++ b/crates/core/src/schema/management.rs @@ -1,5 +1,7 @@ extern crate alloc; +use alloc::borrow::ToOwned; +use alloc::collections::btree_map::BTreeMap; use alloc::rc::Rc; use alloc::string::String; use alloc::vec::Vec; @@ -12,8 +14,13 @@ use sqlite::{Connection, ResultCode, Value}; use crate::error::{PSResult, PowerSyncError}; use crate::ext::ExtendedDatabase; +use crate::schema::inspection::ExistingView; use crate::state::DatabaseState; use crate::util::{quote_identifier, quote_json_path}; +use crate::views::{ + powersync_trigger_delete_sql, powersync_trigger_insert_sql, powersync_trigger_update_sql, + powersync_view_sql, +}; use crate::{create_auto_tx_function, create_sqlite_text_fn}; use super::Schema; @@ -236,55 +243,46 @@ SELECT Ok(()) } -fn update_views(db: *mut sqlite::sqlite3, schema: &str) -> Result<(), PowerSyncError> { - // Update existing views if modified - // language=SQLite - db.exec_text("\ -UPDATE powersync_views SET -sql = gen.sql, -delete_trigger_sql = gen.delete_trigger_sql, -insert_trigger_sql = gen.insert_trigger_sql, -update_trigger_sql = gen.update_trigger_sql -FROM (SELECT - ifnull(json_extract(json_each.value, '$.view_name'), json_extract(json_each.value, '$.name')) as name, - powersync_view_sql(json_each.value) as sql, - powersync_trigger_delete_sql(json_each.value) as delete_trigger_sql, - powersync_trigger_insert_sql(json_each.value) as insert_trigger_sql, - powersync_trigger_update_sql(json_each.value) as update_trigger_sql - FROM json_each(json_extract(?, '$.tables'))) as gen - WHERE powersync_views.name = gen.name AND - (powersync_views.sql IS NOT gen.sql OR - powersync_views.delete_trigger_sql IS NOT gen.delete_trigger_sql OR - powersync_views.insert_trigger_sql IS NOT gen.insert_trigger_sql OR - powersync_views.update_trigger_sql IS NOT gen.update_trigger_sql) - ", schema).into_db_result(db)?; - - // Create new views - // language=SQLite - db.exec_text("\ -INSERT INTO powersync_views( - name, - sql, - delete_trigger_sql, - insert_trigger_sql, - update_trigger_sql -) -SELECT -ifnull(json_extract(json_each.value, '$.view_name'), json_extract(json_each.value, '$.name')) as name, - powersync_view_sql(json_each.value) as sql, - powersync_trigger_delete_sql(json_each.value) as delete_trigger_sql, - powersync_trigger_insert_sql(json_each.value) as insert_trigger_sql, - powersync_trigger_update_sql(json_each.value) as update_trigger_sql - FROM json_each(json_extract(?, '$.tables')) - WHERE name NOT IN (SELECT name FROM powersync_views)", schema).into_db_result(db)?; - - // Delete old views - // language=SQLite - db.exec_text("\ -DELETE FROM powersync_views WHERE name NOT IN ( - SELECT ifnull(json_extract(json_each.value, '$.view_name'), json_extract(json_each.value, '$.name')) - FROM json_each(json_extract(?, '$.tables')) - )", schema).into_db_result(db)?; +fn update_views(db: *mut sqlite::sqlite3, schema: &Schema) -> Result<(), PowerSyncError> { + // First, find all existing views and index them by name. + let existing = ExistingView::list(db)?; + let mut existing = { + let mut map = BTreeMap::new(); + for entry in &existing { + map.insert(&*entry.name, entry); + } + map + }; + + for table in &schema.tables { + let view_sql = powersync_view_sql(table); + let delete_trigger_sql = powersync_trigger_delete_sql(table)?; + let insert_trigger_sql = powersync_trigger_insert_sql(table)?; + let update_trigger_sql = powersync_trigger_update_sql(table)?; + + let wanted_view = ExistingView { + name: table.view_name().to_owned(), + sql: view_sql, + delete_trigger_sql, + insert_trigger_sql, + update_trigger_sql, + }; + + if let Some(actual_view) = existing.remove(table.view_name()) { + if *actual_view == wanted_view { + // View exists with identical definition, don't re-create. + continue; + } + } + + // View does not exist or has been defined differently, re-create. + wanted_view.create(db)?; + } + + // Delete old views. + for remaining in existing.values() { + ExistingView::drop_by_name(db, &remaining.name)?; + } Ok(()) } @@ -309,7 +307,7 @@ fn powersync_replace_schema_impl( update_tables(db, schema)?; update_indexes(db, &parsed_schema)?; - update_views(db, schema)?; + update_views(db, &parsed_schema)?; state.set_schema(parsed_schema); Ok(String::from("")) diff --git a/crates/core/src/schema/mod.rs b/crates/core/src/schema/mod.rs index 9b22c879..a8c7c886 100644 --- a/crates/core/src/schema/mod.rs +++ b/crates/core/src/schema/mod.rs @@ -1,3 +1,4 @@ +pub mod inspection; mod management; mod table_info; diff --git a/crates/core/src/schema/table_info.rs b/crates/core/src/schema/table_info.rs index 363859cd..d8a6f218 100644 --- a/crates/core/src/schema/table_info.rs +++ b/crates/core/src/schema/table_info.rs @@ -29,10 +29,6 @@ pub struct RawTable { } impl Table { - pub fn from_json(text: &str) -> Result { - serde_json::from_str(text) - } - pub fn view_name(&self) -> &str { self.view_name_override .as_deref() diff --git a/crates/core/src/view_admin.rs b/crates/core/src/view_admin.rs index cd7794a9..4ee57509 100644 --- a/crates/core/src/view_admin.rs +++ b/crates/core/src/view_admin.rs @@ -12,49 +12,25 @@ use sqlite::{ResultCode, Value}; 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::{create_auto_tx_function, create_sqlite_text_fn}; -fn powersync_drop_view_impl( +// Used in old down migrations, do not remove. +extern "C" fn powersync_drop_view( ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { + argc: c_int, + argv: *mut *mut sqlite::value, +) { + let args = sqlite::args!(argc, argv); let name = args[0].text(); - let local_db = ctx.db_handle(); - let q = format!("DROP VIEW IF EXISTS {:}", quote_identifier(name)); - let stmt2 = local_db.prepare_v2(&q)?; - - if stmt2.step()? == ResultCode::ROW { - Ok(String::from(name)) - } else { - Ok(String::from("")) - } -} - -create_sqlite_text_fn!( - powersync_drop_view, - powersync_drop_view_impl, - "powersync_drop_view" -); - -fn powersync_exec_impl( - ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { - let q = args[0].text(); - - if q != "" { - let local_db = ctx.db_handle(); - local_db.exec_safe(q)?; + if let Err(e) = ExistingView::drop_by_name(ctx.db_handle(), name) { + e.apply_to_ctx("powersync_drop_view", ctx); } - - Ok(String::from("")) } -create_sqlite_text_fn!(powersync_exec, powersync_exec_impl, "powersync_exec"); - fn powersync_internal_table_name_impl( ctx: *mut sqlite::context, args: &[*mut sqlite::value], @@ -244,58 +220,8 @@ create_auto_tx_function!(powersync_clear_tx, powersync_clear_impl); create_sqlite_text_fn!(powersync_clear, powersync_clear_tx, "powersync_clear"); fn setup_internal_views(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { - // powersync_views - just filters sqlite_master, and combines the view and related triggers - // into one row. - - // These views are only usable while the extension is loaded, so use TEMP views. // TODO: This should not be a public view - implement internally instead // language=SQLite - db.exec_safe("\ - CREATE TEMP VIEW IF NOT EXISTS powersync_views(name, sql, delete_trigger_sql, insert_trigger_sql, update_trigger_sql) - AS SELECT - view.name name, - view.sql sql, - ifnull(group_concat(trigger1.sql, ';\n' ORDER BY trigger1.name DESC), '') delete_trigger_sql, - ifnull(trigger2.sql, '') insert_trigger_sql, - ifnull(trigger3.sql, '') update_trigger_sql - FROM sqlite_master view - LEFT JOIN sqlite_master trigger1 - ON trigger1.tbl_name = view.name AND trigger1.type = 'trigger' AND trigger1.name GLOB 'ps_view_delete*' - LEFT JOIN sqlite_master trigger2 - ON trigger2.tbl_name = view.name AND trigger2.type = 'trigger' AND trigger2.name GLOB 'ps_view_insert*' - LEFT JOIN sqlite_master trigger3 - ON trigger3.tbl_name = view.name AND trigger3.type = 'trigger' AND trigger3.name GLOB 'ps_view_update*' - WHERE view.type = 'view' AND view.sql GLOB '*-- powersync-auto-generated' - GROUP BY view.name; - - CREATE TRIGGER IF NOT EXISTS powersync_views_insert - INSTEAD OF INSERT ON powersync_views - FOR EACH ROW - BEGIN - SELECT powersync_drop_view(NEW.name); - SELECT powersync_exec(NEW.sql); - SELECT powersync_exec(NEW.delete_trigger_sql); - SELECT powersync_exec(NEW.insert_trigger_sql); - SELECT powersync_exec(NEW.update_trigger_sql); - END; - - CREATE TRIGGER IF NOT EXISTS powersync_views_update - INSTEAD OF UPDATE ON powersync_views - FOR EACH ROW - BEGIN - SELECT powersync_drop_view(OLD.name); - SELECT powersync_exec(NEW.sql); - SELECT powersync_exec(NEW.delete_trigger_sql); - SELECT powersync_exec(NEW.insert_trigger_sql); - SELECT powersync_exec(NEW.update_trigger_sql); - END; - - CREATE TRIGGER IF NOT EXISTS powersync_views_delete - INSTEAD OF DELETE ON powersync_views - FOR EACH ROW - BEGIN - SELECT powersync_drop_view(OLD.name); - END;")?; // language=SQLite db.exec_safe( @@ -314,27 +240,8 @@ fn setup_internal_views(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<(), ResultCode> { // This entire module is just making it easier to edit sqlite_master using queries. - // The primary interfaces exposed are: - // 1. Individual views: - // - // CREATE VIEW powersync_views(name TEXT, sql TEXT, delete_trigger_sql TEXT, insert_trigger_sql TEXT, update_trigger_sql TEXT) - // - // The views can be queried and updated using powersync_views. - // UPSERT is not supported on powersync_views (or any view or virtual table for that matter), - // but "INSERT OR REPLACE" is supported. However, it's a potentially expensive operation - // (drops and re-creates the view and trigger), so avoid where possible. - // - // 2. All-in-one schema updates: - // - // INSERT INTO powersync_replace_schema(schema) VALUES('{"tables": [...]}'); - // - // This takes care of updating, inserting and deleting powersync_views to get it in sync - // with the schema. - // - // The same results could be achieved using virtual tables, but the interface would remain the same. - // A potential disadvantage of using views is that the JSON may be re-parsed multiple times. - - // Internal function, used in triggers for powersync_views. + + // Internal function, used exclusively in existing migrations. db.create_function_v2( "powersync_drop_view", 1, @@ -346,19 +253,7 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() None, )?; - // Internal function, used in triggers for powersync_views. - db.create_function_v2( - "powersync_exec", - 1, - sqlite::UTF8, - None, - Some(powersync_exec), - None, - None, - None, - )?; - - // Initialize the extension internal tables. + // Initialize the extension internal tables, and start a migration. db.create_function_v2( "powersync_init", 0, @@ -381,7 +276,6 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() None, )?; - // Initialize the extension internal tables. db.create_function_v2( "powersync_clear", 1, diff --git a/crates/core/src/views.rs b/crates/core/src/views.rs index eca98824..909feb91 100644 --- a/crates/core/src/views.rs +++ b/crates/core/src/views.rs @@ -4,23 +4,13 @@ use alloc::borrow::Cow; use alloc::format; use alloc::string::String; use alloc::vec::Vec; -use core::ffi::c_int; use core::fmt::Write; -use powersync_sqlite_nostd::{self as sqlite}; -use sqlite::{Connection, Context, ResultCode, Value}; - -use crate::create_sqlite_text_fn; use crate::error::PowerSyncError; use crate::schema::{Column, DiffIncludeOld, Table}; use crate::util::*; -fn powersync_view_sql_impl( - _ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { - let table_info = Table::from_json(args[0].text()).map_err(PowerSyncError::as_argument_error)?; - +pub fn powersync_view_sql(table_info: &Table) -> String { let name = &table_info.name; let view_name = &table_info.view_name(); let local_only = table_info.flags.local_only(); @@ -59,21 +49,10 @@ fn powersync_view_sql_impl( internal_name ); - return Ok(view_statement); + return view_statement; } -create_sqlite_text_fn!( - powersync_view_sql, - powersync_view_sql_impl, - "powersync_view_sql" -); - -fn powersync_trigger_delete_sql_impl( - _ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { - let table_info = Table::from_json(args[0].text()).map_err(PowerSyncError::as_argument_error)?; - +pub fn powersync_trigger_delete_sql(table_info: &Table) -> Result { let name = &table_info.name; let view_name = &table_info.view_name(); let local_only = table_info.flags.local_only(); @@ -153,18 +132,7 @@ END", }; } -create_sqlite_text_fn!( - powersync_trigger_delete_sql, - powersync_trigger_delete_sql_impl, - "powersync_trigger_delete_sql" -); - -fn powersync_trigger_insert_sql_impl( - _ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { - let table_info = Table::from_json(args[0].text()).map_err(PowerSyncError::as_argument_error)?; - +pub fn powersync_trigger_insert_sql(table_info: &Table) -> Result { let name = &table_info.name; let view_name = &table_info.view_name(); let local_only = table_info.flags.local_only(); @@ -226,18 +194,7 @@ fn powersync_trigger_insert_sql_impl( }; } -create_sqlite_text_fn!( - powersync_trigger_insert_sql, - powersync_trigger_insert_sql_impl, - "powersync_trigger_insert_sql" -); - -fn powersync_trigger_update_sql_impl( - _ctx: *mut sqlite::context, - args: &[*mut sqlite::value], -) -> Result { - let table_info = Table::from_json(args[0].text()).map_err(PowerSyncError::as_argument_error)?; - +pub fn powersync_trigger_update_sql(table_info: &Table) -> Result { let name = &table_info.name; let view_name = &table_info.view_name(); let insert_only = table_info.flags.insert_only(); @@ -346,60 +303,6 @@ END" }; } -create_sqlite_text_fn!( - powersync_trigger_update_sql, - powersync_trigger_update_sql_impl, - "powersync_trigger_update_sql" -); - -pub fn register(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { - db.create_function_v2( - "powersync_view_sql", - 1, - sqlite::UTF8 | sqlite::DETERMINISTIC | sqlite::DIRECTONLY, - None, - Some(powersync_view_sql), - None, - None, - None, - )?; - - db.create_function_v2( - "powersync_trigger_delete_sql", - 1, - sqlite::UTF8 | sqlite::DETERMINISTIC | sqlite::DIRECTONLY, - None, - Some(powersync_trigger_delete_sql), - None, - None, - None, - )?; - - db.create_function_v2( - "powersync_trigger_insert_sql", - 1, - sqlite::UTF8 | sqlite::DETERMINISTIC | sqlite::DIRECTONLY, - None, - Some(powersync_trigger_insert_sql), - None, - None, - None, - )?; - - db.create_function_v2( - "powersync_trigger_update_sql", - 1, - sqlite::UTF8 | sqlite::DETERMINISTIC | sqlite::DIRECTONLY, - None, - Some(powersync_trigger_update_sql), - None, - None, - None, - )?; - - Ok(()) -} - /// Given a query returning column names, return a JSON object fragment for a trigger. /// /// Example output with prefix "NEW": "json_object('id', NEW.id, 'name', NEW.name, 'age', NEW.age)".