Skip to main content

max / synckit

28.9 KB · 906 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use crate::types::{ChangeEntry, ChangeOp, hlc_legacy_floor};
5 use rusqlite::Connection;
6 use serde_json::json;
7
8 use super::super::db::configure_connection;
9 use super::super::schema::{SyncSchema, SyncTable};
10
11 fn upsert(table: &str, row_id: &str, data: Value) -> ChangeEntry {
12 ChangeEntry {
13 table: table.into(),
14 op: ChangeOp::Insert,
15 row_id: row_id.into(),
16 timestamp: chrono::Utc::now(),
17 hlc: hlc_legacy_floor(),
18 data: Some(data),
19 extra: serde_json::Map::default(),
20 }
21 }
22
23 fn delete(table: &str, row_id: &str, data: Value) -> ChangeEntry {
24 ChangeEntry {
25 op: ChangeOp::Delete,
26 ..upsert(table, row_id, data)
27 }
28 }
29
30 fn schema() -> SyncSchema {
31 SyncSchema::new(vec![
32 SyncTable::full("parent", &["id", "name"]),
33 SyncTable::full("child", &["id", "parent_id", "note"]),
34 SyncTable::full("acct", &["id", "name"])
35 .preserve_local(&["secret"])
36 .insert_defaults(&[("secret", "")]),
37 SyncTable::full("tagpair", &["a", "b"]).pk(&["a", "b"]),
38 SyncTable::full("items", &["id", "is_read", "is_starred"])
39 .partial_update(&["is_read", "is_starred"])
40 .ignore_deletes(),
41 SyncTable::full("samp", &["hash", "name", "deleted_at"])
42 .pk(&["hash"])
43 .hashed()
44 .tombstone("deleted_at"),
45 SyncTable::full("cfg", &["key", "value"])
46 .pk(&["key"])
47 .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\'"),
48 SyncTable::full("reffer", &["id", "ext_id"]).references_unsynced(),
49 // `kind` is NOT NULL *with a default*, the only shape in which
50 // omitting a column and binding an explicit NULL differ observably.
51 SyncTable::full("note", &["id", "body", "kind"]),
52 // A preserved column that is also a whitelist column, so a payload
53 // can carry it and the ON CONFLICT SET has to refuse it.
54 SyncTable::full("vault", &["id", "label", "token"]).preserve_local(&["token"]),
55 // Partial update on a composite key: two WHERE bindings, not one.
56 SyncTable::full("pairflag", &["a", "b", "flag"])
57 .pk(&["a", "b"])
58 .partial_update(&["flag"]),
59 // INTEGER PRIMARY KEY, so a text id is a datatype mismatch: a SQLite
60 // failure that is not a constraint violation.
61 SyncTable::full("tally", &["id", "label"]),
62 ])
63 }
64
65 fn db() -> Connection {
66 let conn = Connection::open_in_memory().unwrap();
67 configure_connection(&conn).unwrap();
68 conn.execute_batch(
69 "
70 CREATE TABLE parent (id TEXT PRIMARY KEY, name TEXT);
71 CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id) ON DELETE CASCADE, note TEXT);
72 CREATE TABLE acct (id TEXT PRIMARY KEY, name TEXT, secret TEXT NOT NULL);
73 CREATE TABLE tagpair (a TEXT, b TEXT, PRIMARY KEY (a, b));
74 CREATE TABLE items (id TEXT PRIMARY KEY, is_read INTEGER, is_starred INTEGER, title TEXT);
75 CREATE TABLE ghost (id INTEGER PRIMARY KEY);
76 CREATE TABLE samp (hash TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER);
77 CREATE TABLE cfg (key TEXT PRIMARY KEY, value TEXT);
78 CREATE TABLE reffer (id TEXT PRIMARY KEY, ext_id INTEGER NOT NULL REFERENCES ghost(id));
79 CREATE TABLE note (id TEXT PRIMARY KEY, body TEXT, kind TEXT NOT NULL DEFAULT 'plain');
80 CREATE TABLE vault (id TEXT PRIMARY KEY, label TEXT, token TEXT);
81 CREATE TABLE pairflag (a TEXT, b TEXT, flag INTEGER, PRIMARY KEY (a, b));
82 CREATE TABLE tally (id INTEGER PRIMARY KEY, label TEXT);
83 ",
84 )
85 .unwrap();
86 let s = schema();
87 conn.execute_batch(&s.migration_sql()).unwrap();
88 conn
89 }
90
91 /// These tests exercise the applier, not the pipeline that feeds it, so they
92 /// build the resolved batch directly rather than routing every case through
93 /// `resolve_pull`.
94 fn apply(conn: &mut Connection, changes: &[ChangeEntry]) -> ApplyOutcome {
95 let changes = ResolvedChanges::for_test(changes.to_vec());
96 apply_remote_changes(conn, &schema(), &changes, "").unwrap()
97 }
98
99 #[test]
100 fn full_insert_then_update_via_on_conflict() {
101 let mut conn = db();
102 let o = apply(
103 &mut conn,
104 &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
105 );
106 assert_eq!(o.applied, 1);
107 assert!(o.changed_tables.contains("parent"));
108 apply(
109 &mut conn,
110 &[upsert("parent", "p1", json!({"id":"p1","name":"b"}))],
111 );
112 let name: String = conn
113 .query_row("SELECT name FROM parent WHERE id='p1'", [], |r| r.get(0))
114 .unwrap();
115 assert_eq!(name, "b");
116 }
117
118 #[test]
119 fn on_conflict_update_does_not_cascade_to_children() {
120 let mut conn = db();
121 apply(
122 &mut conn,
123 &[
124 upsert("parent", "p1", json!({"id":"p1","name":"a"})),
125 upsert(
126 "child",
127 "c1",
128 json!({"id":"c1","parent_id":"p1","note":"n"}),
129 ),
130 ],
131 );
132 // Re-upsert the parent; the child must survive (ON CONFLICT DO UPDATE, not REPLACE).
133 apply(
134 &mut conn,
135 &[upsert("parent", "p1", json!({"id":"p1","name":"a2"}))],
136 );
137 let kids: i64 = conn
138 .query_row("SELECT COUNT(*) FROM child", [], |r| r.get(0))
139 .unwrap();
140 assert_eq!(kids, 1);
141 }
142
143 #[test]
144 fn preserve_local_and_insert_defaults() {
145 let mut conn = db();
146 // First insert: secret defaults to '' (satisfies NOT NULL); payload never carries it.
147 apply(
148 &mut conn,
149 &[upsert("acct", "a1", json!({"id":"a1","name":"n1"}))],
150 );
151 // Locally the user sets a real secret.
152 conn.execute("UPDATE acct SET secret='hunter2' WHERE id='a1'", [])
153 .unwrap();
154 // A remote update to config columns must NOT clobber the local secret.
155 apply(
156 &mut conn,
157 &[upsert("acct", "a1", json!({"id":"a1","name":"n2"}))],
158 );
159 let (name, secret): (String, String) = conn
160 .query_row("SELECT name, secret FROM acct WHERE id='a1'", [], |r| {
161 Ok((r.get(0)?, r.get(1)?))
162 })
163 .unwrap();
164 assert_eq!(name, "n2");
165 assert_eq!(
166 secret, "hunter2",
167 "preserved secret must survive a remote update"
168 );
169 }
170
171 #[test]
172 fn null_tolerance_omits_not_null_but_keeps_nullable_null() {
173 let mut conn = db();
174 apply(
175 &mut conn,
176 &[upsert("parent", "p1", json!({"id":"p1","name":"start"}))],
177 );
178 // name is nullable → an explicit null clears it.
179 apply(
180 &mut conn,
181 &[upsert("parent", "p1", json!({"id":"p1","name":null}))],
182 );
183 let name: Option<String> = conn
184 .query_row("SELECT name FROM parent WHERE id='p1'", [], |r| r.get(0))
185 .unwrap();
186 assert_eq!(name, None);
187 // A null for a NOT NULL column (child.parent_id) is omitted, so an insert
188 // takes no value for it → constraint violation → deferred, not fatal.
189 let o = apply(
190 &mut conn,
191 &[upsert(
192 "child",
193 "c1",
194 json!({"id":"c1","parent_id":null,"note":"x"}),
195 )],
196 );
197 assert_eq!(o.applied, 0);
198 assert_eq!(o.deferred.len(), 1);
199 assert_eq!(o.deferred[0].row_id, "c1");
200 }
201
202 #[test]
203 fn all_pk_table_uses_insert_or_ignore() {
204 let mut conn = db();
205 let o = apply(
206 &mut conn,
207 &[upsert("tagpair", "x", json!({"a":"x","b":"y"}))],
208 );
209 assert_eq!(o.applied, 1);
210 // Re-applying the same all-PK row is a no-op, not an error.
211 let o2 = apply(
212 &mut conn,
213 &[upsert("tagpair", "x", json!({"a":"x","b":"y"}))],
214 );
215 assert_eq!(o2.applied, 1); // executed, 0 rows changed, still Ok
216 let n: i64 = conn
217 .query_row("SELECT COUNT(*) FROM tagpair", [], |r| r.get(0))
218 .unwrap();
219 assert_eq!(n, 1);
220 }
221
222 #[test]
223 fn partial_update_touches_only_set_columns() {
224 let mut conn = db();
225 conn.execute(
226 "INSERT INTO items (id, is_read, is_starred, title) VALUES ('i1', 0, 0, 'keep')",
227 [],
228 )
229 .unwrap();
230 apply(
231 &mut conn,
232 &[ChangeEntry {
233 op: ChangeOp::Update,
234 ..upsert("items", "i1", json!({"id":"i1","is_read":1,"is_starred":0}))
235 }],
236 );
237 let (read, title): (i64, String) = conn
238 .query_row("SELECT is_read, title FROM items WHERE id='i1'", [], |r| {
239 Ok((r.get(0)?, r.get(1)?))
240 })
241 .unwrap();
242 assert_eq!(read, 1);
243 assert_eq!(
244 title, "keep",
245 "partial update must not touch non-set columns"
246 );
247 }
248
249 #[test]
250 fn hard_delete_and_ignore_delete() {
251 let mut conn = db();
252 apply(
253 &mut conn,
254 &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
255 );
256 let o = apply(&mut conn, &[delete("parent", "p1", json!({"id":"p1"}))]);
257 assert_eq!(o.applied, 1);
258 assert_eq!(
259 conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
260 .unwrap(),
261 0
262 );
263
264 // items ignore deletes.
265 conn.execute(
266 "INSERT INTO items (id, is_read, is_starred) VALUES ('i1', 1, 0)",
267 [],
268 )
269 .unwrap();
270 let o2 = apply(&mut conn, &[delete("items", "i1", json!({"id":"i1"}))]);
271 assert_eq!(o2.applied, 0);
272 assert_eq!(
273 conn.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
274 .unwrap(),
275 1
276 );
277 }
278
279 #[test]
280 fn tombstone_delete_sets_column_and_keeps_earliest() {
281 let mut conn = db();
282 conn.execute("INSERT INTO samp (hash, name) VALUES ('h1', 's')", [])
283 .unwrap();
284 // A hashed table's delete carries the PK in data; the opaque row_id is ignored.
285 apply(
286 &mut conn,
287 &[delete("samp", "OPAQUE_HASH", json!({"hash":"h1"}))],
288 );
289 let (present, del): (i64, Option<i64>) = conn
290 .query_row(
291 "SELECT COUNT(*), MAX(deleted_at) FROM samp WHERE hash='h1'",
292 [],
293 |r| Ok((r.get(0)?, r.get(1)?)),
294 )
295 .unwrap();
296 assert_eq!(present, 1, "tombstone keeps the row");
297 let first = del.unwrap();
298 // Re-deleting keeps the earliest instant (COALESCE).
299 apply(
300 &mut conn,
301 &[delete("samp", "OPAQUE_HASH", json!({"hash":"h1"}))],
302 );
303 let second: i64 = conn
304 .query_row("SELECT deleted_at FROM samp WHERE hash='h1'", [], |r| {
305 r.get(0)
306 })
307 .unwrap();
308 assert_eq!(first, second);
309 }
310
311 #[test]
312 fn exclude_where_guards_import_both_ways() {
313 let mut conn = db();
314 let o = apply(
315 &mut conn,
316 &[
317 upsert(
318 "cfg",
319 "sync_cursor",
320 json!({"key":"sync_cursor","value":"9"}),
321 ), // excluded
322 upsert("cfg", "theme", json!({"key":"theme","value":"dark"})), // included
323 ],
324 );
325 assert_eq!(o.applied, 1);
326 let keys: Vec<String> = {
327 let mut s = conn.prepare("SELECT key FROM cfg ORDER BY key").unwrap();
328 s.query_map([], |r| r.get(0))
329 .unwrap()
330 .map(|r| r.unwrap())
331 .collect()
332 };
333 assert_eq!(keys, vec!["theme".to_string()]);
334 // A hostile delete of an excluded key is also dropped.
335 conn.execute(
336 "INSERT INTO cfg (key, value) VALUES ('sync_secret', 'x')",
337 [],
338 )
339 .unwrap();
340 let o2 = apply(
341 &mut conn,
342 &[delete("cfg", "sync_secret", json!({"key":"sync_secret"}))],
343 );
344 assert_eq!(o2.applied, 0);
345 assert_eq!(
346 conn.query_row(
347 "SELECT COUNT(*) FROM cfg WHERE key='sync_secret'",
348 [],
349 |r| r.get::<_, i64>(0)
350 )
351 .unwrap(),
352 1
353 );
354 }
355
356 #[test]
357 fn fk_ordering_parents_before_children_children_before_parents() {
358 let mut conn = db();
359 // Child listed before parent in the batch, but FK enforced, must still apply
360 // because the engine orders upserts parents-first.
361 let o = apply(
362 &mut conn,
363 &[
364 upsert(
365 "child",
366 "c1",
367 json!({"id":"c1","parent_id":"p1","note":"n"}),
368 ),
369 upsert("parent", "p1", json!({"id":"p1","name":"a"})),
370 ],
371 );
372 assert_eq!(o.applied, 2);
373 // Delete both; children-first ordering means the child goes before the parent.
374 let o2 = apply(
375 &mut conn,
376 &[
377 delete("parent", "p1", json!({"id":"p1"})),
378 delete("child", "c1", json!({"id":"c1"})),
379 ],
380 );
381 assert_eq!(o2.applied, 2);
382 }
383
384 #[test]
385 fn references_unsynced_relaxes_fk() {
386 let mut conn = db();
387 // reffer.ext_id points at a ghost row that does not exist and is not synced.
388 // Without FK relaxation this would be a constraint violation.
389 let o = apply(
390 &mut conn,
391 &[upsert("reffer", "r1", json!({"id":"r1","ext_id":999}))],
392 );
393 assert_eq!(
394 o.applied, 1,
395 "references_unsynced disables FK for the apply"
396 );
397 // FK enforcement is restored afterward.
398 let fk: i64 = conn
399 .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
400 .unwrap();
401 assert_eq!(fk, 1);
402 }
403
404 #[test]
405 fn constraint_violation_is_skipped_not_fatal() {
406 let mut conn = db();
407 // First row violates FK (no parent p9); second is valid. Batch must not abort.
408 let o = apply(
409 &mut conn,
410 &[
411 upsert(
412 "child",
413 "bad",
414 json!({"id":"bad","parent_id":"p9","note":"x"}),
415 ),
416 upsert("parent", "p1", json!({"id":"p1","name":"ok"})),
417 ],
418 );
419 assert_eq!(o.applied, 1);
420 assert_eq!(o.deferred.len(), 1, "the poison row is held, not lost");
421 assert_eq!(
422 conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
423 .unwrap(),
424 1
425 );
426 }
427
428 #[test]
429 fn unknown_table_change_is_deferred_not_dropped() {
430 let mut conn = db();
431 let o = apply(&mut conn, &[upsert("nonexistent", "x", json!({"id":"x"}))]);
432 assert_eq!(o.applied, 0);
433 // Deferred rather than rejected: the table may exist after a client
434 // upgrade, and then the held entry applies.
435 assert_eq!(o.deferred.len(), 1);
436 assert_eq!(o.deferred[0].table, "nonexistent");
437 assert!(o.rejected.is_empty());
438 }
439
440 #[test]
441 fn an_excluded_row_is_filtered_not_held() {
442 let mut conn = db();
443 // cfg's include predicate is "key NOT LIKE 'sync_%'", so a sync_ key is
444 // excluded on import. That is policy, not failure, and must never reach
445 // the dead-letter.
446 let o = apply(
447 &mut conn,
448 &[upsert(
449 "cfg",
450 "sync_token",
451 json!({"key":"sync_token","value":"x"}),
452 )],
453 );
454 assert_eq!(o.applied, 0);
455 assert_eq!(o.filtered, 1);
456 assert!(o.deferred.is_empty());
457 assert!(o.rejected.is_empty());
458 }
459
460 #[test]
461 fn a_payloadless_upsert_is_rejected_not_deferred() {
462 let mut conn = db();
463 let mut change = upsert("parent", "p1", json!({"id":"p1"}));
464 change.data = None;
465 let o = apply(&mut conn, &[change]);
466 assert_eq!(o.applied, 0);
467 assert_eq!(
468 o.rejected.len(),
469 1,
470 "identical bytes would fail identically"
471 );
472 assert!(o.deferred.is_empty());
473 }
474
475 #[test]
476 fn fk_sweep_catches_what_the_batch_wide_relaxation_hides() {
477 let mut conn = db();
478 // `reffer` declares references_unsynced, so the whole apply runs with
479 // foreign_keys=OFF. Without the sweep, the child row below lands with a
480 // missing parent and nothing is reported.
481 let o = apply(
482 &mut conn,
483 &[
484 upsert("reffer", "r1", json!({"id":"r1","ext_id":404})),
485 upsert(
486 "child",
487 "c1",
488 json!({"id":"c1","parent_id":"missing","note":"x"}),
489 ),
490 ],
491 );
492
493 assert_eq!(
494 conn.query_row("SELECT COUNT(*) FROM child", [], |r| r.get::<_, i64>(0))
495 .unwrap(),
496 0,
497 "the orphan is removed, not left to resurface later"
498 );
499 assert_eq!(
500 conn.query_row("SELECT COUNT(*) FROM reffer", [], |r| r.get::<_, i64>(0))
501 .unwrap(),
502 1,
503 "the table the relaxation exists for keeps its row"
504 );
505 assert_eq!(o.applied, 1);
506 assert_eq!(o.deferred.len(), 1);
507 assert_eq!(o.deferred[0].table, "child");
508 assert_eq!(o.deferred[0].row_id, "c1");
509 }
510
511 #[test]
512 fn fk_sweep_leaves_a_row_whose_parent_is_present() {
513 let mut conn = db();
514 let o = apply(
515 &mut conn,
516 &[
517 upsert("reffer", "r1", json!({"id":"r1","ext_id":404})),
518 upsert("parent", "p1", json!({"id":"p1","name":"ok"})),
519 upsert(
520 "child",
521 "c1",
522 json!({"id":"c1","parent_id":"p1","note":"x"}),
523 ),
524 ],
525 );
526 assert_eq!(o.applied, 3);
527 assert!(o.deferred.is_empty(), "a satisfied FK is not a violation");
528 }
529
530 #[test]
531 fn unapplied_totals_the_three_not_applied_kinds() {
532 let mut conn = db();
533 let o = apply(
534 &mut conn,
535 &[
536 // applied: an ordinary parent row.
537 upsert("parent", "p1", json!({"id":"p1","name":"a"})),
538 // filtered x3: two excluded cfg keys and one delete on a table
539 // that ignores deletes.
540 upsert("cfg", "sync_a", json!({"key":"sync_a","value":"1"})),
541 upsert("cfg", "sync_b", json!({"key":"sync_b","value":"2"})),
542 delete("items", "i1", json!({"id":"i1"})),
543 // rejected x1: no payload, so the same bytes always fail.
544 ChangeEntry {
545 data: None,
546 ..upsert("parent", "p2", json!({"id":"p2"}))
547 },
548 // deferred x2: an unknown table and a missing FK parent.
549 upsert("nonexistent", "x", json!({"id":"x"})),
550 upsert(
551 "child",
552 "c1",
553 json!({"id":"c1","parent_id":"absent","note":"n"}),
554 ),
555 ],
556 );
557
558 assert_eq!(o.applied, 1);
559 assert_eq!(o.filtered, 3);
560 assert_eq!(o.rejected.len(), 1);
561 assert_eq!(o.deferred.len(), 2);
562 // The derived total is the sum of exactly those three, and excludes
563 // `applied`. Each part is non-zero and distinct, so no constant and no
564 // pair of the three adds up to it.
565 assert_eq!(o.unapplied(), 6);
566 assert_eq!(
567 o.unapplied(),
568 o.filtered + o.rejected.len() + o.deferred.len()
569 );
570 }
571
572 #[test]
573 fn unapplied_is_zero_on_a_clean_apply() {
574 let mut conn = db();
575 let o = apply(
576 &mut conn,
577 &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
578 );
579 assert_eq!(o.applied, 1);
580 assert_eq!(o.unapplied(), 0, "an applied row is not an unapplied one");
581 }
582
583 #[test]
584 fn a_composite_delete_keys_off_the_payload_not_the_row_id() {
585 let mut conn = db();
586 apply(
587 &mut conn,
588 &[
589 upsert("tagpair", "ignored", json!({"a":"x","b":"y"})),
590 upsert("tagpair", "ignored", json!({"a":"x","b":"z"})),
591 ],
592 );
593 // The wire row id names nothing this table can be keyed by; the payload
594 // carries the whole composite key, so exactly one row goes.
595 let o = apply(
596 &mut conn,
597 &[delete("tagpair", "not-a-key", json!({"a":"x","b":"y"}))],
598 );
599 assert_eq!(o.applied, 1);
600 assert!(o.rejected.is_empty());
601 let left: Vec<String> = conn
602 .prepare("SELECT b FROM tagpair WHERE a='x' ORDER BY b")
603 .unwrap()
604 .query_map([], |r| r.get(0))
605 .unwrap()
606 .map(std::result::Result::unwrap)
607 .collect();
608 assert_eq!(left, vec!["z".to_string()], "the wrong row was deleted");
609 }
610
611 #[test]
612 fn a_null_component_of_a_composite_key_is_rejected_not_bound() {
613 let mut conn = db();
614 apply(
615 &mut conn,
616 &[
617 upsert("tagpair", "ignored", json!({"a":"x","b":"y"})),
618 upsert("tagpair", "ignored", json!({"a":"x","b":"z"})),
619 ],
620 );
621 // `b` is JSON null, so the key cannot be reconstructed. Binding the
622 // partial key would delete every row sharing `a`.
623 let o = apply(
624 &mut conn,
625 &[delete("tagpair", "x", json!({"a":"x","b":null}))],
626 );
627 assert_eq!(o.applied, 0);
628 assert_eq!(o.rejected.len(), 1);
629 assert_eq!(o.rejected[0].table, "tagpair");
630 assert!(o.deferred.is_empty());
631 let rows: i64 = conn
632 .query_row("SELECT COUNT(*) FROM tagpair", [], |r| r.get(0))
633 .unwrap();
634 assert_eq!(rows, 2, "a null key component must touch nothing");
635 }
636
637 #[test]
638 fn a_partial_update_with_a_null_key_is_rejected() {
639 let mut conn = db();
640 conn.execute(
641 "INSERT INTO items (id, is_read, is_starred, title) VALUES ('i1', 0, 0, 't')",
642 [],
643 )
644 .unwrap();
645 // The partial-update path never falls back to the wire row id, so a null
646 // `id` in the payload has no key at all.
647 let o = apply(
648 &mut conn,
649 &[upsert("items", "i1", json!({"id":null,"is_read":1}))],
650 );
651 assert_eq!(o.applied, 0);
652 assert_eq!(o.rejected.len(), 1);
653 assert!(o.deferred.is_empty());
654 let is_read: i64 = conn
655 .query_row("SELECT is_read FROM items WHERE id='i1'", [], |r| r.get(0))
656 .unwrap();
657 assert_eq!(is_read, 0, "the keyless update must not have landed");
658 }
659
660 #[test]
661 fn a_present_key_in_the_payload_beats_the_wire_row_id() {
662 let mut conn = db();
663 conn.execute(
664 "INSERT INTO items (id, is_read, is_starred, title) VALUES ('i1', 0, 0, 'one')",
665 [],
666 )
667 .unwrap();
668 conn.execute(
669 "INSERT INTO items (id, is_read, is_starred, title) VALUES ('i2', 0, 0, 'two')",
670 [],
671 )
672 .unwrap();
673 let o = apply(
674 &mut conn,
675 &[upsert("items", "i1", json!({"id":"i2","is_read":1}))],
676 );
677 assert_eq!(o.applied, 1);
678 let read: Vec<i64> = conn
679 .prepare("SELECT is_read FROM items ORDER BY id")
680 .unwrap()
681 .query_map([], |r| r.get(0))
682 .unwrap()
683 .map(std::result::Result::unwrap)
684 .collect();
685 assert_eq!(read, vec![0, 1], "the payload key names the row to update");
686 }
687
688 #[test]
689 fn a_non_constraint_sqlite_failure_rolls_the_whole_batch_back() {
690 let mut conn = db();
691 // tally.id is an INTEGER PRIMARY KEY, so a text id SQLite cannot coerce
692 // raises SQLITE_MISMATCH, not SQLITE_CONSTRAINT. Only a constraint
693 // violation is survivable; anything else means the batch cannot be
694 // trusted, so it must surface as Err rather than as a deferred row.
695 let changes = ResolvedChanges::for_test(vec![
696 upsert("parent", "p1", json!({"id":"p1","name":"a"})),
697 upsert("tally", "t1", json!({"id":"notanint","label":"x"})),
698 ]);
699 let e = apply_remote_changes(&mut conn, &schema(), &changes, "")
700 .expect_err("a non-constraint SQLite error must not be swallowed as deferred");
701 assert!(matches!(e, crate::error::SyncKitError::Database(_)));
702 assert_eq!(
703 conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
704 .unwrap(),
705 0,
706 "the valid row earlier in the same batch rolls back with it"
707 );
708 assert_eq!(
709 conn.query_row("SELECT COUNT(*) FROM tally", [], |r| r.get::<_, i64>(0))
710 .unwrap(),
711 0
712 );
713 }
714
715 #[test]
716 fn a_null_for_a_defaulted_not_null_column_is_omitted_not_bound() {
717 let mut conn = db();
718 // note.kind is NOT NULL DEFAULT 'plain'. Omitting it (what the NOT NULL
719 // set is read for) takes the default; binding an explicit NULL would
720 // violate instead, so the two are distinguishable here.
721 let o = apply(
722 &mut conn,
723 &[upsert(
724 "note",
725 "n1",
726 json!({"id":"n1","body":"b1","kind":null}),
727 )],
728 );
729 assert_eq!(o.applied, 1);
730 assert!(o.deferred.is_empty(), "an omitted column takes its default");
731 let kind: String = conn
732 .query_row("SELECT kind FROM note WHERE id='n1'", [], |r| r.get(0))
733 .unwrap();
734 assert_eq!(kind, "plain");
735
736 // Same on the update leg: the column is left out of the ON CONFLICT SET,
737 // so a local value stands rather than being nulled.
738 conn.execute("UPDATE note SET kind='code' WHERE id='n1'", [])
739 .unwrap();
740 let o2 = apply(
741 &mut conn,
742 &[upsert(
743 "note",
744 "n1",
745 json!({"id":"n1","body":"b2","kind":null}),
746 )],
747 );
748 assert_eq!(o2.applied, 1);
749 assert!(o2.deferred.is_empty());
750 let (body, kind): (String, String) = conn
751 .query_row("SELECT body, kind FROM note WHERE id='n1'", [], |r| {
752 Ok((r.get(0)?, r.get(1)?))
753 })
754 .unwrap();
755 assert_eq!(body, "b2", "a nullable column still updates");
756 assert_eq!(
757 kind, "code",
758 "a NOT NULL column the payload nulled keeps its local value"
759 );
760 }
761
762 #[test]
763 fn a_preserved_column_carried_in_the_payload_is_still_not_overwritten() {
764 let mut conn = db();
765 // vault.token is both a whitelist column and preserve_local, so the
766 // payload can carry it and the ON CONFLICT SET must still refuse it.
767 // Only the preserve filter keeps it out; the PK filter would not.
768 let o = apply(
769 &mut conn,
770 &[upsert(
771 "vault",
772 "v1",
773 json!({"id":"v1","label":"l1","token":"seed"}),
774 )],
775 );
776 assert_eq!(o.applied, 1);
777 conn.execute("UPDATE vault SET token='local' WHERE id='v1'", [])
778 .unwrap();
779 apply(
780 &mut conn,
781 &[upsert(
782 "vault",
783 "v1",
784 json!({"id":"v1","label":"l2","token":"remote"}),
785 )],
786 );
787 let (label, token): (String, String) = conn
788 .query_row("SELECT label, token FROM vault WHERE id='v1'", [], |r| {
789 Ok((r.get(0)?, r.get(1)?))
790 })
791 .unwrap();
792 assert_eq!(label, "l2", "a non-preserved column still updates");
793 assert_eq!(
794 token, "local",
795 "preserve_local outranks a payload that carries the column"
796 );
797 }
798
799 #[test]
800 fn a_pre_existing_orphan_survives_a_sweep_of_its_own_table() {
801 let mut conn = db();
802 // An orphan left by an earlier relaxed apply. No entry in this batch
803 // describes it, so there is nothing to hold and deleting it would lose
804 // the row for good.
805 conn.execute_batch(
806 "PRAGMA foreign_keys=OFF;
807 INSERT INTO child (id, parent_id, note) VALUES ('ghost1', 'gone', 'g');
808 PRAGMA foreign_keys=ON;",
809 )
810 .unwrap();
811 // `reffer` turns the batch-wide relaxation on, so the sweep runs over
812 // `child`. The batch names the table but not this row.
813 let o = apply(
814 &mut conn,
815 &[
816 upsert("reffer", "r1", json!({"id":"r1","ext_id":404})),
817 upsert("parent", "p1", json!({"id":"p1","name":"a"})),
818 upsert(
819 "child",
820 "c1",
821 json!({"id":"c1","parent_id":"p1","note":"n"}),
822 ),
823 ],
824 );
825 assert_eq!(o.applied, 3);
826 assert!(
827 o.deferred.is_empty(),
828 "a row this batch never pulled cannot be deferred for retry"
829 );
830 let ids: Vec<String> = conn
831 .prepare("SELECT id FROM child ORDER BY id")
832 .unwrap()
833 .query_map([], |r| r.get(0))
834 .unwrap()
835 .map(std::result::Result::unwrap)
836 .collect();
837 assert_eq!(
838 ids,
839 vec!["c1".to_string(), "ghost1".to_string()],
840 "matching the table alone is not matching the row"
841 );
842 }
843
844 #[test]
845 fn a_composite_partial_update_binds_every_key_component() {
846 let mut conn = db();
847 conn.execute_batch("INSERT INTO pairflag (a, b, flag) VALUES ('x','y',0), ('x','z',0);")
848 .unwrap();
849 let o = apply(
850 &mut conn,
851 &[ChangeEntry {
852 op: ChangeOp::Update,
853 ..upsert("pairflag", "x:z", json!({"a":"x","b":"z","flag":1}))
854 }],
855 );
856 assert_eq!(o.applied, 1);
857 let flags: Vec<(String, i64)> = conn
858 .prepare("SELECT b, flag FROM pairflag ORDER BY b")
859 .unwrap()
860 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
861 .unwrap()
862 .map(std::result::Result::unwrap)
863 .collect();
864 // Each key component needs its own placeholder: reusing the first would
865 // compare `b` against the value of `a` and touch the wrong row, or none.
866 assert_eq!(
867 flags,
868 vec![("y".to_string(), 0), ("z".to_string(), 1)],
869 "only the row the whole composite key names is updated"
870 );
871 }
872
873 #[test]
874 fn a_delete_with_no_payload_falls_back_to_the_wire_row_id() {
875 let mut conn = db();
876 apply(
877 &mut conn,
878 &[
879 upsert("parent", "p1", json!({"id":"p1","name":"a"})),
880 upsert("parent", "p2", json!({"id":"p2","name":"b"})),
881 ],
882 );
883 // An older client wrote the key into the wire row id and sent no payload.
884 // A single-PK table can still be addressed from it.
885 let mut change = delete("parent", "p1", json!({}));
886 change.data = None;
887 let o = apply(&mut conn, &[change]);
888 assert_eq!(o.applied, 1);
889 assert!(
890 o.rejected.is_empty(),
891 "a single-PK delete is reconstructable from the row id alone"
892 );
893 let left: Vec<String> = conn
894 .prepare("SELECT id FROM parent ORDER BY id")
895 .unwrap()
896 .query_map([], |r| r.get(0))
897 .unwrap()
898 .map(std::result::Result::unwrap)
899 .collect();
900 assert_eq!(
901 left,
902 vec!["p2".to_string()],
903 "the row the wire id names, and only it, goes"
904 );
905 }
906