Skip to content

Commit 60ed658

Browse files
committed
Add tests for created triggers
1 parent 672264c commit 60ed658

5 files changed

Lines changed: 202 additions & 30 deletions

File tree

crates/core/src/schema/common.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use crate::schema::{
66
Column, CommonTableOptions, RawTable, Table, raw_table::InferredTableStructure,
77
};
88

9+
/// Utility to wrap both PowerSync-managed JSON tables and raw tables (with their schema snapshot
10+
/// inferred from reading `pragma_table_info`) into a common implementation.
911
pub enum SchemaTable<'a> {
1012
Json(&'a Table),
1113
Raw {
@@ -25,6 +27,7 @@ impl<'a> SchemaTable<'a> {
2527
}
2628
}
2729

30+
/// Iterates over defined column names in this table (not including the `id` column).
2831
pub fn column_names(&self) -> impl Iterator<Item = &'a str> {
2932
match self {
3033
Self::Json(table) => SchemaTableColumnIterator::Json(table.columns.iter()),

crates/core/src/schema/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ pub use table_info::{
1515
};
1616

1717
use crate::{
18-
error::PowerSyncError, schema::raw_table::generate_raw_table_trigger, state::DatabaseState,
18+
error::{PSResult, PowerSyncError},
19+
schema::raw_table::generate_raw_table_trigger,
20+
state::DatabaseState,
1921
utils::WriteType,
2022
};
2123

@@ -43,7 +45,7 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc<DatabaseState>) -> Result<()
4345
let db = context.db_handle();
4446
let create_trigger_stmt =
4547
generate_raw_table_trigger(db, &table, trigger_name, write_type)?;
46-
db.exec_safe(&create_trigger_stmt)?;
48+
db.exec_safe(&create_trigger_stmt).into_db_result(db)?;
4749
Ok(())
4850
}
4951

crates/core/src/schema/raw_table.rs

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use core::fmt::from_fn;
1+
use core::fmt::{self, Formatter, from_fn};
22

33
use alloc::{
44
format,
@@ -105,6 +105,20 @@ pub fn generate_raw_table_trigger(
105105
None
106106
};
107107

108+
let write_data = from_fn(|f: &mut Formatter| -> fmt::Result {
109+
write!(f, "json(powersync_diff(")?;
110+
111+
if let Some(ref old) = json_fragment_old {
112+
f.write_str(old)?;
113+
} else {
114+
// We don't have OLD values for inserts, we diff from an empty JSON object
115+
// instead.
116+
f.write_str("'{}'")?;
117+
};
118+
119+
write!(f, ", {json_fragment_new}))")
120+
});
121+
108122
buffer.insert_into_powersync_crud(InsertIntoCrud {
109123
op: write,
110124
table: &as_schema_table,
@@ -114,32 +128,11 @@ pub fn generate_raw_table_trigger(
114128
"NEW.id"
115129
},
116130
type_name: &table.name,
117-
data: Some(&from_fn(|f| {
118-
match write {
119-
WriteType::Insert => {}
120-
WriteType::Update => todo!(),
121-
WriteType::Delete => {
122-
// There is no data for deleted rows, don't emit anything.
123-
}
124-
}
125-
126-
if write == WriteType::Delete {
127-
// There is no data for deleted rows.
128-
return Ok(());
129-
}
130-
131-
write!(f, "json(powersync_diff(")?;
132-
133-
if let Some(ref old) = json_fragment_old {
134-
f.write_str(old)?;
135-
} else {
136-
// We don't have OLD values for inserts, we diff from an empty JSON object
137-
// instead.
138-
f.write_str("'{}'")?;
139-
};
140-
141-
write!(f, ", {json_fragment_new}))")
142-
})),
131+
data: match write {
132+
// There is no data for deleted rows.
133+
WriteType::Delete => None,
134+
_ => Some(&write_data),
135+
},
143136
metadata: None::<&'static str>,
144137
})?;
145138
}

crates/core/src/utils/sql_buffer.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ impl SqlBuffer {
127127
include_old.column_filter(),
128128
)?;
129129

130-
if options.flags.include_old_only_when_changed() {
130+
if insert.op == WriteType::Update
131+
&& options.flags.include_old_only_when_changed()
132+
{
131133
// When include_old_only_when_changed is combined with a column filter, make sure we
132134
// only include the powersync_diff of columns matched by the filter.
133135
let filtered_new_fragment = table_columns_to_json_object_with_filter(

dart/test/crud_test.dart

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:sqlite3/common.dart';
44
import 'package:test/test.dart';
55

66
import 'utils/native_test_utils.dart';
7+
import 'utils/test_utils.dart';
78

89
void main() {
910
group('crud tests', () {
@@ -774,5 +775,176 @@ void main() {
774775
expect(db.select('SELECT * FROM ps_crud'), isEmpty);
775776
});
776777
});
778+
779+
group('raw tables', () {
780+
void createRawTableTriggers(Object table,
781+
{bool insert = true, bool update = true, bool delete = true}) {
782+
db.execute('SELECT powersync_init()');
783+
784+
if (insert) {
785+
db.execute('SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)',
786+
[json.encode(table), 'test_trigger_insert', 'INSERT']);
787+
}
788+
if (update) {
789+
db.execute('SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)',
790+
[json.encode(table), 'test_trigger_update', 'UPDATE']);
791+
}
792+
if (delete) {
793+
db.execute('SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)',
794+
[json.encode(table), 'test_trigger_delete', 'DELETE']);
795+
}
796+
}
797+
798+
Object rawTableDescription(Map<String, Object?> options) {
799+
return {
800+
'name': 'row_type',
801+
'put': {'sql': '', 'params': []},
802+
'delete': {'sql': '', 'params': []},
803+
...options,
804+
};
805+
}
806+
807+
test('missing id column', () {
808+
db.execute('CREATE TABLE users (name TEXT);');
809+
expect(
810+
() => createRawTableTriggers(
811+
rawTableDescription({'table_name': 'users'})),
812+
throwsA(isSqliteException(
813+
3091, contains('Table users has no id column'))),
814+
);
815+
});
816+
817+
test('missing local table name', () {
818+
db.execute('CREATE TABLE users (name TEXT);');
819+
expect(
820+
() => createRawTableTriggers(rawTableDescription({})),
821+
throwsA(isSqliteException(3091, contains('Table has no local name'))),
822+
);
823+
});
824+
825+
test('missing local table', () {
826+
expect(
827+
() => createRawTableTriggers(
828+
rawTableDescription({'table_name': 'users'})),
829+
throwsA(isSqliteException(
830+
3091, contains('Could not find users in local schema'))),
831+
);
832+
});
833+
834+
test('default options', () {
835+
db.execute('CREATE TABLE users (id TEXT, name TEXT) STRICT;');
836+
createRawTableTriggers(rawTableDescription({'table_name': 'users'}));
837+
838+
db
839+
..execute(
840+
'INSERT INTO users (id, name) VALUES (?, ?)', ['id', 'name'])
841+
..execute('UPDATE users SET name = ?', ['new name'])
842+
..execute('DELETE FROM users WHERE id = ?', ['id']);
843+
844+
final psCrud = db.select('SELECT * FROM ps_crud');
845+
expect(psCrud, [
846+
{
847+
'id': 1,
848+
'tx_id': 1,
849+
'data': json.encode({
850+
'op': 'PUT',
851+
'id': 'id',
852+
'type': 'row_type',
853+
'data': {'name': 'name'}
854+
}),
855+
},
856+
{
857+
'id': 2,
858+
'tx_id': 2,
859+
'data': json.encode({
860+
'op': 'PATCH',
861+
'id': 'id',
862+
'type': 'row_type',
863+
'data': {'name': 'new name'}
864+
}),
865+
},
866+
{
867+
'id': 3,
868+
'tx_id': 3,
869+
'data':
870+
json.encode({'op': 'DELETE', 'id': 'id', 'type': 'row_type'}),
871+
},
872+
]);
873+
});
874+
875+
test('insert only', () {
876+
db.execute('CREATE TABLE users (id TEXT, name TEXT) STRICT;');
877+
createRawTableTriggers(
878+
rawTableDescription({'table_name': 'users', 'insert_only': true}));
879+
880+
db.execute(
881+
'INSERT INTO users (id, name) VALUES (?, ?)', ['id', 'name']);
882+
expect(db.select('SELECT * FROM ps_crud'), hasLength(1));
883+
884+
// Should not update the $local bucket
885+
expect(db.select('SELECT * FROM ps_buckets'), hasLength(0));
886+
887+
// The trigger should prevent other writes.
888+
expect(
889+
() => db.execute('UPDATE users SET name = ?', ['new name']),
890+
throwsA(isSqliteException(
891+
1811, contains('Unexpected update on insert-only table'))));
892+
expect(
893+
() => db.execute('DELETE FROM users WHERE id = ?', ['id']),
894+
throwsA(isSqliteException(
895+
1811, contains('Unexpected update on insert-only table'))));
896+
});
897+
898+
test('tracking old values', () {
899+
db.execute(
900+
'CREATE TABLE users (id TEXT, name TEXT, email TEXT) STRICT;');
901+
createRawTableTriggers(rawTableDescription({
902+
'table_name': 'users',
903+
'include_old': ['name'],
904+
'include_old_only_when_changed': true,
905+
}));
906+
907+
db
908+
..execute('INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
909+
['id', 'name', 'test@example.org'])
910+
..execute('UPDATE users SET name = ?, email = ?',
911+
['new name', 'newmail@example.org'])
912+
..execute('DELETE FROM users WHERE id = ?', ['id']);
913+
914+
final psCrud = db.select(
915+
r"SELECT id, data->>'$.op' AS op, data->>'$.old' as old FROM ps_crud");
916+
expect(psCrud, [
917+
{
918+
'id': 1,
919+
'op': 'PUT',
920+
'old': null,
921+
},
922+
{
923+
'id': 2,
924+
'op': 'PATCH',
925+
'old': json.encode({'name': 'name'}),
926+
},
927+
{
928+
'id': 3,
929+
'op': 'DELETE',
930+
'old': json.encode({'name': 'new name'}),
931+
},
932+
]);
933+
});
934+
935+
test('skipping empty updates', () {
936+
db.execute('CREATE TABLE users (id TEXT, name TEXT) STRICT;');
937+
createRawTableTriggers(rawTableDescription(
938+
{'table_name': 'users', 'ignore_empty_update': true}));
939+
940+
db.execute(
941+
'INSERT INTO users (id, name) VALUES (?, ?)', ['id', 'name']);
942+
expect(db.select('SELECT * FROM ps_crud'), hasLength(1));
943+
944+
// Empty update should not be recorded
945+
db.execute('UPDATE users SET name = ?', ['name']);
946+
expect(db.select('SELECT * FROM ps_crud'), hasLength(1));
947+
});
948+
});
777949
});
778950
}

0 commit comments

Comments
 (0)