Skip to content

Commit 041dcdb

Browse files
committed
Add table option to skip empty updates
1 parent 5d64f36 commit 041dcdb

File tree

7 files changed

+148
-17
lines changed

7 files changed

+148
-17
lines changed

Cargo.lock

+27
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ num-traits = { version = "0.2.15", default-features = false }
1919
num-derive = "0.3"
2020
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
2121
serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] }
22+
const_format = "0.2.34"
2223

2324
[dependencies.uuid]
2425
version = "1.4.1"

crates/core/src/crud_vtab.rs

+65-13
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use alloc::string::String;
55
use core::ffi::{c_char, c_int, c_void};
66
use core::slice;
77

8+
use const_format::formatcp;
89
use sqlite::{Connection, ResultCode, Value};
910
use sqlite_nostd as sqlite;
1011
use sqlite_nostd::ManagedStmt;
@@ -15,7 +16,7 @@ use crate::ext::SafeManagedStmt;
1516
use crate::vtab_util::*;
1617

1718
// Structure:
18-
// CREATE TABLE powersync_crud_(data TEXT);
19+
// CREATE TABLE powersync_crud_(data TEXT, options INT HIDDEN);
1920
//
2021
// This is a insert-only virtual table. It generates transaction ids in ps_tx, and inserts data in
2122
// ps_crud(tx_id, data).
@@ -29,9 +30,13 @@ struct VirtualTable {
2930
base: sqlite::vtab,
3031
db: *mut sqlite::sqlite3,
3132
current_tx: Option<i64>,
32-
insert_statement: Option<ManagedStmt>
33+
insert_statement: Option<ManagedStmt>,
3334
}
3435

36+
#[repr(transparent)]
37+
#[derive(Clone, Copy)]
38+
pub struct PowerSyncCrudFlags(pub u32);
39+
3540
extern "C" fn connect(
3641
db: *mut sqlite::sqlite3,
3742
_aux: *mut c_void,
@@ -40,8 +45,10 @@ extern "C" fn connect(
4045
vtab: *mut *mut sqlite::vtab,
4146
_err: *mut *mut c_char,
4247
) -> c_int {
43-
if let Err(rc) = sqlite::declare_vtab(db, "CREATE TABLE powersync_crud_(data TEXT);")
44-
{
48+
if let Err(rc) = sqlite::declare_vtab(
49+
db,
50+
"CREATE TABLE powersync_crud_(data TEXT, options INT HIDDEN);",
51+
) {
4552
return rc as c_int;
4653
}
4754

@@ -54,7 +61,7 @@ extern "C" fn connect(
5461
},
5562
db,
5663
current_tx: None,
57-
insert_statement: None
64+
insert_statement: None,
5865
}));
5966
*vtab = tab.cast::<sqlite::vtab>();
6067
let _ = sqlite::vtab_config(db, 0);
@@ -69,15 +76,22 @@ extern "C" fn disconnect(vtab: *mut sqlite::vtab) -> c_int {
6976
ResultCode::OK as c_int
7077
}
7178

72-
7379
fn begin_impl(tab: &mut VirtualTable) -> Result<(), SQLiteError> {
7480
let db = tab.db;
7581

76-
let insert_statement = db.prepare_v3("INSERT INTO ps_crud(tx_id, data) VALUES (?1, ?2)", 0)?;
82+
const SQL: &str = formatcp!("\
83+
WITH insertion (tx_id, data) AS (VALUES (?1, ?2))
84+
INSERT INTO ps_crud(tx_id, data)
85+
SELECT * FROM insertion WHERE (?3 & {}) OR data->>'op' != 'PATCH' OR EXISTS (SELECT 1 FROM json_each(data->'data'));
86+
", PowerSyncCrudFlags::FLAG_INCLUDE_EMPTY_UPDATE);
87+
88+
// language=SQLite
89+
let insert_statement = db.prepare_v3(SQL, 0)?;
7790
tab.insert_statement = Some(insert_statement);
7891

7992
// language=SQLite
80-
let statement = db.prepare_v2("UPDATE ps_tx SET next_tx = next_tx + 1 WHERE id = 1 RETURNING next_tx")?;
93+
let statement =
94+
db.prepare_v2("UPDATE ps_tx SET next_tx = next_tx + 1 WHERE id = 1 RETURNING next_tx")?;
8195
if statement.step()? == ResultCode::ROW {
8296
let tx_id = statement.column_int64(0)? - 1;
8397
tab.current_tx = Some(tx_id);
@@ -110,22 +124,31 @@ extern "C" fn rollback(vtab: *mut sqlite::vtab) -> c_int {
110124
}
111125

112126
fn insert_operation(
113-
vtab: *mut sqlite::vtab, data: &str) -> Result<(), SQLiteError> {
127+
vtab: *mut sqlite::vtab,
128+
data: &str,
129+
flags: PowerSyncCrudFlags,
130+
) -> Result<(), SQLiteError> {
114131
let tab = unsafe { &mut *(vtab.cast::<VirtualTable>()) };
115132
if tab.current_tx.is_none() {
116-
return Err(SQLiteError(ResultCode::MISUSE, Some(String::from("No tx_id"))));
133+
return Err(SQLiteError(
134+
ResultCode::MISUSE,
135+
Some(String::from("No tx_id")),
136+
));
117137
}
118138
let current_tx = tab.current_tx.unwrap();
119139
// language=SQLite
120-
let statement = tab.insert_statement.as_ref().ok_or(SQLiteError::from(NULL))?;
140+
let statement = tab
141+
.insert_statement
142+
.as_ref()
143+
.ok_or(SQLiteError::from(NULL))?;
121144
statement.bind_int64(1, current_tx)?;
122145
statement.bind_text(2, data, sqlite::Destructor::STATIC)?;
146+
statement.bind_int(3, flags.0 as i32)?;
123147
statement.exec()?;
124148

125149
Ok(())
126150
}
127151

128-
129152
extern "C" fn update(
130153
vtab: *mut sqlite::vtab,
131154
argc: c_int,
@@ -142,7 +165,13 @@ extern "C" fn update(
142165
} else if rowid.value_type() == sqlite::ColumnType::Null {
143166
// INSERT
144167
let data = args[2].text();
145-
let result = insert_operation(vtab, data);
168+
let flags = match args[3].value_type() {
169+
// We don't ignore empty updates by default.
170+
sqlite_nostd::ColumnType::Null => PowerSyncCrudFlags::default(),
171+
_ => PowerSyncCrudFlags(args[3].int() as u32),
172+
};
173+
174+
let result = insert_operation(vtab, data, flags);
146175
vtab_result(vtab, result)
147176
} else {
148177
// UPDATE - not supported
@@ -185,3 +214,26 @@ pub fn register(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> {
185214

186215
Ok(())
187216
}
217+
218+
impl PowerSyncCrudFlags {
219+
pub const FLAG_INCLUDE_EMPTY_UPDATE: u32 = 1 << 0;
220+
221+
pub fn set_include_empty_update(&mut self, value: bool) {
222+
if value {
223+
self.0 |= Self::FLAG_INCLUDE_EMPTY_UPDATE;
224+
} else {
225+
self.0 &= !Self::FLAG_INCLUDE_EMPTY_UPDATE;
226+
}
227+
}
228+
229+
pub fn has_include_empty_update(self) -> bool {
230+
self.0 & Self::FLAG_INCLUDE_EMPTY_UPDATE != 0
231+
}
232+
}
233+
234+
impl Default for PowerSyncCrudFlags {
235+
fn default() -> Self {
236+
// For backwards-compatibility, we include empty updates by default.
237+
return Self(Self::FLAG_INCLUDE_EMPTY_UPDATE);
238+
}
239+
}

crates/core/src/util.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ pub fn extract_table_info(
5656
json_extract(?1, '$.local_only') as local_only,
5757
json_extract(?1, '$.insert_only') as insert_only,
5858
json_extract(?1, '$.include_old') as include_old,
59-
json_extract(?1, '$.include_metadata') as include_metadata",
59+
json_extract(?1, '$.include_metadata') as include_metadata,
60+
json_extract(?1, '$.ignore_empty_update') as ignore_empty_update",
6061
)?;
6162
statement.bind_text(1, data, sqlite::Destructor::STATIC)?;
6263

crates/core/src/views.rs

+7-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use sqlite::{Connection, Context, ResultCode, Value};
1010
use sqlite_nostd::{self as sqlite, ManagedStmt};
1111

1212
use crate::create_sqlite_text_fn;
13+
use crate::crud_vtab::PowerSyncCrudFlags;
1314
use crate::error::{PSResult, SQLiteError};
1415
use crate::util::*;
1516

@@ -215,6 +216,7 @@ fn powersync_trigger_update_sql_impl(
215216
// TODO: allow accepting a column list
216217
let include_old = statement.column_type(4)? == sqlite::ColumnType::Text;
217218
let include_metadata = statement.column_int(5)? != 0;
219+
let ignore_empty_update = statement.column_int(6)? != 0;
218220

219221
let quoted_name = quote_identifier(view_name);
220222
let internal_name = quote_internal_name(name, local_only);
@@ -242,6 +244,9 @@ fn powersync_trigger_update_sql_impl(
242244
metadata_fragment = "";
243245
}
244246

247+
let mut crud_flags: PowerSyncCrudFlags = PowerSyncCrudFlags::default();
248+
crud_flags.set_include_empty_update(!ignore_empty_update);
249+
245250
return if !local_only && !insert_only {
246251
let trigger = format!("\
247252
CREATE TRIGGER {:}
@@ -255,10 +260,10 @@ BEGIN
255260
UPDATE {:}
256261
SET data = {:}
257262
WHERE id = NEW.id;
258-
INSERT INTO powersync_crud_(data) VALUES(json_object('op', 'PATCH', 'type', {:}, 'id', NEW.id, 'data', json(powersync_diff({:}, {:})){:}{:}));
263+
INSERT INTO powersync_crud_(data, options) VALUES(json_object('op', 'PATCH', 'type', {:}, 'id', NEW.id, 'data', json(powersync_diff({:}, {:})){:}{:}), {:});
259264
INSERT OR IGNORE INTO ps_updated_rows(row_type, row_id) VALUES({:}, NEW.id);
260265
INSERT OR REPLACE INTO ps_buckets(name, last_op, target_op) VALUES('$local', 0, {:});
261-
END", trigger_name, quoted_name, internal_name, json_fragment_new, type_string, json_fragment_old, json_fragment_new, old_fragment, metadata_fragment, type_string, MAX_OP_ID);
266+
END", trigger_name, quoted_name, internal_name, json_fragment_new, type_string, json_fragment_old, json_fragment_new, old_fragment, metadata_fragment, crud_flags.0, type_string, MAX_OP_ID);
262267
Ok(trigger)
263268
} else if local_only {
264269
let trigger = format!(

dart/test/crud_test.dart

+45
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,51 @@ void main() {
6565
expect(r5['diff'], equals('{"b":"test"}'));
6666
});
6767

68+
test('includes empty updates by default', () {
69+
db
70+
..execute('select powersync_replace_schema(?)', [
71+
json.encode({
72+
'tables': [
73+
{
74+
'name': 'items',
75+
'columns': [
76+
{'name': 'col', 'type': 'text'}
77+
],
78+
}
79+
]
80+
})
81+
])
82+
..execute(
83+
'INSERT INTO items (id, col) VALUES (uuid(), ?)', ['new item'])
84+
..execute('UPDATE items SET col = LOWER(col)');
85+
86+
// Should record insert and update operation.
87+
expect(db.select('SELECT * FROM ps_crud'), hasLength(2));
88+
});
89+
90+
test('can ignore empty updates', () {
91+
db
92+
..execute('select powersync_replace_schema(?)', [
93+
json.encode({
94+
'tables': [
95+
{
96+
'name': 'items',
97+
'columns': [
98+
{'name': 'col', 'type': 'text'}
99+
],
100+
'ignore_empty_update': true,
101+
}
102+
]
103+
})
104+
])
105+
..execute(
106+
'INSERT INTO items (id, col) VALUES (uuid(), ?)', ['new item'])
107+
..execute('UPDATE items SET col = LOWER(col)');
108+
109+
// The update which didn't change any rows should not be recorded.
110+
expect(db.select('SELECT * FROM ps_crud'), hasLength(1));
111+
});
112+
68113
var runCrudTest = (int numberOfColumns) {
69114
var columns = [];
70115
for (var i = 0; i < numberOfColumns; i++) {

dart/test/utils/migration_fixtures.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ BEGIN
439439
UPDATE "ps_data__lists"
440440
SET data = json_object('description', NEW."description")
441441
WHERE id = NEW.id;
442-
INSERT INTO powersync_crud_(data) VALUES(json_object('op', 'PATCH', 'type', 'lists', 'id', NEW.id, 'data', json(powersync_diff(json_object('description', OLD."description"), json_object('description', NEW."description")))));
442+
INSERT INTO powersync_crud_(data, options) VALUES(json_object('op', 'PATCH', 'type', 'lists', 'id', NEW.id, 'data', json(powersync_diff(json_object('description', OLD."description"), json_object('description', NEW."description")))), 1);
443443
INSERT OR IGNORE INTO ps_updated_rows(row_type, row_id) VALUES('lists', NEW.id);
444444
INSERT OR REPLACE INTO ps_buckets(name, last_op, target_op) VALUES('$local', 0, 9223372036854775807);
445445
END

0 commit comments

Comments
 (0)