1use crate::query_mapped_rows;
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7
8use crate::foreign_key_edges;
9use coven_protocol::store_commit::ObjectHash;
10use coven_protocol::synced_schema::{GateRole, RowIdentity, SyncedTable};
11
12const SYNC_ROUTING_CONTRACT_VERSION: u32 = 1;
13
14#[derive(Debug, thiserror::Error)]
15pub enum SyncRoutingContractError {
16 #[error(transparent)]
17 Sqlite(#[from] rusqlite::Error),
18 #[error(transparent)]
19 ForeignKey(#[from] crate::ForeignKeySchemaError),
20 #[error("parse sync-routing contract: {0}")]
21 Json(#[from] serde_json::Error),
22 #[error("unsupported sync-routing contract version {0}")]
23 UnsupportedVersion(u32),
24 #[error("sync-routing contract bytes are not canonical")]
25 Noncanonical,
26 #[error("synced table {child_table:?} has a foreign key to undeclared table {parent_table:?}")]
27 UndeclaredForeignKeyTarget {
28 child_table: String,
29 parent_table: String,
30 },
31 #[error(
32 "foreign key from {child_table:?} to {parent_table:?} targets non-primary columns {columns:?} without a matching non-partial UNIQUE key and collation"
33 )]
34 MissingUniqueParentKey {
35 child_table: String,
36 parent_table: String,
37 columns: Vec<String>,
38 },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SyncRoutingContract {
43 bytes: Vec<u8>,
44 hash: ObjectHash,
45 has_scoped_graph: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50struct CanonicalContract {
51 version: u32,
52 tables: Vec<CanonicalTable>,
53 foreign_keys: Vec<CanonicalForeignKey>,
54 parent_unique_keys: Vec<CanonicalUniqueKey>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct CanonicalTable {
60 name: String,
61 row_identity: CanonicalRowIdentity,
62 role: CanonicalRole,
63 audience_parent_column: Option<String>,
64 asset: bool,
65 blob: Option<CanonicalBlob>,
66 required_columns: Vec<CanonicalColumn>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71enum CanonicalRowIdentity {
72 IndependentUuid,
73 SharedKey,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "kind")]
78enum CanonicalRole {
79 Plain,
80 RemoteRoot,
81 GatedRoot { column: String },
82 ScopedRoot { column: String },
83 GatedByDescendants,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88struct CanonicalBlob {
89 id_column: String,
90 size_column: String,
91 hash_column: String,
92 namespace: String,
93 cloud_path_column: Option<String>,
94 scope: CanonicalBlobScope,
95 provenance: CanonicalProvenance,
96 fill: CanonicalCacheFill,
97 replacement: CanonicalBlobReplacement,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
102enum CanonicalBlobScope {
103 Master,
104 Derived(String),
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109enum CanonicalProvenance {
110 UserProvided,
111 HostProvided,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116enum CanonicalCacheFill {
117 CacheEager,
118 CacheLazy,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123enum CanonicalBlobReplacement {
124 Replaceable,
125 WriteOnce,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130struct CanonicalColumn {
131 ordinal: i64,
132 name: String,
133 declared_type: String,
134 not_null: bool,
135 primary_key_rank: i64,
136 collation: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141struct CanonicalForeignKey {
142 child_table: String,
143 parent_table: String,
144 columns: Vec<CanonicalForeignKeyColumn>,
145 on_update: String,
146 on_delete: String,
147 match_clause: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152struct CanonicalForeignKeyColumn {
153 child: CanonicalColumn,
154 parent: CanonicalColumn,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159struct CanonicalUniqueKey {
160 parent_table: String,
161 columns: Vec<CanonicalUniqueKeyColumn>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166struct CanonicalUniqueKeyColumn {
167 column: CanonicalColumn,
168 index_collation: String,
169 descending: bool,
170}
171
172impl SyncRoutingContract {
173 pub(crate) fn from_connection(
174 conn: &rusqlite::Connection,
175 declarations: &[SyncedTable],
176 ) -> Result<Self, SyncRoutingContractError> {
177 let mut declarations = declarations.to_vec();
178 declarations.sort_by(|left, right| left.name().cmp(right.name()));
179 let synced_names = declarations
180 .iter()
181 .map(|table| table.name().to_string())
182 .collect::<BTreeSet<_>>();
183 let mut tables = Vec::with_capacity(declarations.len());
184 let mut foreign_keys = Vec::new();
185 let mut parent_unique_keys = BTreeSet::new();
186 for declaration in declarations {
187 tables.push(canonical_table(conn, &declaration)?);
188 let (table_foreign_keys, table_unique_keys) =
189 canonical_foreign_keys(conn, declaration.name(), &synced_names)?;
190 foreign_keys.extend(table_foreign_keys);
191 parent_unique_keys.extend(table_unique_keys);
192 }
193 foreign_keys.sort();
194 Ok(Self::from_canonical(CanonicalContract {
195 version: SYNC_ROUTING_CONTRACT_VERSION,
196 tables,
197 foreign_keys,
198 parent_unique_keys: parent_unique_keys.into_iter().collect(),
199 }))
200 }
201
202 pub fn from_bytes(bytes: &[u8]) -> Result<Self, SyncRoutingContractError> {
203 let canonical: CanonicalContract = serde_json::from_slice(bytes)?;
204 if canonical.version != SYNC_ROUTING_CONTRACT_VERSION {
205 return Err(SyncRoutingContractError::UnsupportedVersion(
206 canonical.version,
207 ));
208 }
209 let parsed = Self::from_canonical(canonical);
210 if parsed.bytes != bytes {
211 return Err(SyncRoutingContractError::Noncanonical);
212 }
213 Ok(parsed)
214 }
215
216 fn from_canonical(canonical: CanonicalContract) -> Self {
217 let has_scoped_graph = canonical
218 .tables
219 .iter()
220 .any(|table| matches!(table.role, CanonicalRole::ScopedRoot { .. }));
221 let bytes = serde_json::to_vec(&canonical)
222 .expect("SyncRoutingContract canonical serialization cannot fail");
223 Self {
224 hash: ObjectHash::digest(&bytes),
225 bytes,
226 has_scoped_graph,
227 }
228 }
229
230 pub fn bytes(&self) -> &[u8] {
231 &self.bytes
232 }
233
234 pub fn hash(&self) -> ObjectHash {
235 self.hash
236 }
237
238 pub fn has_scoped_graph(&self) -> bool {
239 self.has_scoped_graph
240 }
241}
242
243fn canonical_table(
244 conn: &rusqlite::Connection,
245 declaration: &SyncedTable,
246) -> Result<CanonicalTable, SyncRoutingContractError> {
247 let columns = live_columns(conn, declaration.name())?;
248 let role = match declaration.gate_role() {
249 GateRole::Plain => CanonicalRole::Plain,
250 GateRole::RemoteRoot => CanonicalRole::RemoteRoot,
251 GateRole::GatedRoot { gate_column } => CanonicalRole::GatedRoot {
252 column: gate_column.clone(),
253 },
254 GateRole::ScopedRoot { audience_column } => CanonicalRole::ScopedRoot {
255 column: audience_column.clone(),
256 },
257 GateRole::GatedByDescendants => CanonicalRole::GatedByDescendants,
258 };
259 let blob = declaration.blob().map(|blob| CanonicalBlob {
260 id_column: blob.id_column.clone(),
261 size_column: blob.size_column.clone(),
262 hash_column: blob.hash_column.clone(),
263 namespace: blob.namespace.clone(),
264 cloud_path_column: blob.cloud_path_column.clone(),
265 scope: match &blob.scope {
266 coven_protocol::blob::BlobScope::Master => CanonicalBlobScope::Master,
267 coven_protocol::blob::BlobScope::Derived(name) => {
268 CanonicalBlobScope::Derived(name.clone())
269 }
270 },
271 provenance: match blob.provenance {
272 coven_protocol::blob::Provenance::UserProvided => CanonicalProvenance::UserProvided,
273 coven_protocol::blob::Provenance::HostProvided => CanonicalProvenance::HostProvided,
274 },
275 fill: match blob.fill {
276 coven_protocol::blob::CacheFill::CacheEager => CanonicalCacheFill::CacheEager,
277 coven_protocol::blob::CacheFill::CacheLazy => CanonicalCacheFill::CacheLazy,
278 },
279 replacement: match blob.replacement {
280 coven_protocol::blob::BlobReplacement::Replaceable => {
281 CanonicalBlobReplacement::Replaceable
282 }
283 coven_protocol::blob::BlobReplacement::WriteOnce => CanonicalBlobReplacement::WriteOnce,
284 },
285 });
286 let mut required_names = BTreeSet::from(["id".to_string(), "_updated_at".to_string()]);
287 if let Some(column) = declaration.gate_column() {
288 required_names.insert(column.to_string());
289 }
290 if let Some(column) = declaration.audience_column() {
291 required_names.insert(column.to_string());
292 }
293 if let Some(column) = declaration.audience_parent_column() {
294 required_names.insert(column.to_string());
295 }
296 if let Some(blob) = declaration.blob() {
297 required_names.insert(blob.id_column.clone());
298 required_names.insert(blob.size_column.clone());
299 required_names.insert(blob.hash_column.clone());
300 if let Some(column) = &blob.cloud_path_column {
301 required_names.insert(column.clone());
302 }
303 }
304 let required_columns = required_names
305 .into_iter()
306 .map(|name| {
307 columns
308 .get(&name)
309 .cloned()
310 .ok_or_else(|| rusqlite::Error::InvalidColumnName(name).into())
311 })
312 .collect::<Result<Vec<_>, SyncRoutingContractError>>()?;
313 Ok(CanonicalTable {
314 name: declaration.name().to_string(),
315 row_identity: match declaration.row_identity() {
316 RowIdentity::IndependentUuid => CanonicalRowIdentity::IndependentUuid,
317 RowIdentity::SharedKey => CanonicalRowIdentity::SharedKey,
318 },
319 role,
320 audience_parent_column: declaration.audience_parent_column().map(str::to_string),
321 asset: declaration.is_asset(),
322 blob,
323 required_columns,
324 })
325}
326
327fn canonical_foreign_keys(
328 conn: &rusqlite::Connection,
329 child_table: &str,
330 synced_names: &BTreeSet<String>,
331) -> Result<(Vec<CanonicalForeignKey>, Vec<CanonicalUniqueKey>), SyncRoutingContractError> {
332 let child_columns = live_columns(conn, child_table)?;
333 let mut foreign_keys = Vec::new();
334 let mut parent_unique_keys = BTreeSet::new();
335 for edge in foreign_key_edges(conn, child_table)? {
336 if !synced_names.contains(&edge.parent_table) {
337 return Err(SyncRoutingContractError::UndeclaredForeignKeyTarget {
338 child_table: child_table.to_string(),
339 parent_table: edge.parent_table,
340 });
341 }
342 let parent_columns = live_columns(conn, &edge.parent_table)?;
343 let columns = edge
344 .columns
345 .iter()
346 .map(|column| {
347 let child = child_columns
348 .get(&column.child)
349 .cloned()
350 .ok_or_else(|| rusqlite::Error::InvalidColumnName(column.child.clone()))?;
351 let parent = parent_columns
352 .get(&column.parent)
353 .cloned()
354 .ok_or_else(|| rusqlite::Error::InvalidColumnName(column.parent.clone()))?;
355 Ok(CanonicalForeignKeyColumn { child, parent })
356 })
357 .collect::<rusqlite::Result<Vec<_>>>()?;
358 let target_columns = edge
359 .columns
360 .iter()
361 .map(|column| column.parent.clone())
362 .collect::<Vec<_>>();
363 let mut primary_key = parent_columns
364 .values()
365 .filter(|column| column.primary_key_rank > 0)
366 .collect::<Vec<_>>();
367 primary_key.sort_by_key(|column| column.primary_key_rank);
368 let primary_key = primary_key
369 .into_iter()
370 .map(|column| column.name.clone())
371 .collect::<Vec<_>>();
372 if target_columns != primary_key {
373 let unique_keys = canonical_unique_parent_keys(
374 conn,
375 &edge.parent_table,
376 &target_columns,
377 &parent_columns,
378 )?;
379 if unique_keys.is_empty() {
380 return Err(SyncRoutingContractError::MissingUniqueParentKey {
381 child_table: child_table.to_string(),
382 parent_table: edge.parent_table,
383 columns: target_columns,
384 });
385 }
386 parent_unique_keys.extend(unique_keys);
387 }
388 foreign_keys.push(CanonicalForeignKey {
389 child_table: child_table.to_string(),
390 parent_table: edge.parent_table,
391 columns,
392 on_update: edge.on_update,
393 on_delete: edge.on_delete,
394 match_clause: edge.match_clause,
395 });
396 }
397 foreign_keys.sort();
398 Ok((foreign_keys, parent_unique_keys.into_iter().collect()))
399}
400
401fn live_columns(
402 conn: &rusqlite::Connection,
403 table: &str,
404) -> Result<BTreeMap<String, CanonicalColumn>, SyncRoutingContractError> {
405 let sql = format!("PRAGMA table_info({})", crate::quote_ident(table));
406 let mut statement = conn.prepare(&sql)?;
407 let rows = statement.query_map([], |row| {
408 Ok((
409 row.get::<_, i64>(0)?,
410 row.get::<_, String>(1)?,
411 row.get::<_, String>(2)?,
412 row.get::<_, i64>(3)? != 0,
413 row.get::<_, i64>(5)?,
414 ))
415 })?;
416 let mut columns = BTreeMap::new();
417 for row in rows {
418 let (ordinal, name, declared_type, not_null, primary_key_rank) = row?;
419 let (_, collation, _, _, _) = conn.column_metadata(None::<&str>, table, name.as_str())?;
420 let collation = collation
421 .ok_or_else(|| rusqlite::Error::InvalidColumnName(name.clone()))?
422 .to_str()
423 .map_err(|error| rusqlite::Error::Utf8Error(0, error))?
424 .to_ascii_uppercase();
425 columns.insert(
426 name.clone(),
427 CanonicalColumn {
428 ordinal,
429 name,
430 declared_type: declared_type.to_ascii_uppercase(),
431 not_null,
432 primary_key_rank,
433 collation,
434 },
435 );
436 }
437 Ok(columns)
438}
439
440fn canonical_unique_parent_keys(
441 conn: &rusqlite::Connection,
442 parent_table: &str,
443 target_columns: &[String],
444 parent_columns: &BTreeMap<String, CanonicalColumn>,
445) -> Result<Vec<CanonicalUniqueKey>, SyncRoutingContractError> {
446 let sql = format!("PRAGMA index_list({})", crate::quote_ident(parent_table));
447 let indexes = query_mapped_rows(conn, &sql, [], |row| {
448 Ok((
449 row.get::<_, String>(1)?,
450 row.get::<_, i64>(2)? != 0,
451 row.get::<_, i64>(4)? != 0,
452 ))
453 })?;
454 let mut keys = BTreeSet::new();
455 for (index_name, unique, partial) in indexes {
456 if !unique || partial {
457 continue;
458 }
459 let sql = format!("PRAGMA index_xinfo({})", crate::quote_ident(&index_name));
460 let rows = query_mapped_rows(conn, &sql, [], |row| {
461 Ok((
462 row.get::<_, i64>(0)?,
463 row.get::<_, Option<String>>(2)?,
464 row.get::<_, i64>(3)? != 0,
465 row.get::<_, Option<String>>(4)?,
466 row.get::<_, i64>(5)? != 0,
467 ))
468 })?;
469 let mut index_columns = rows
470 .into_iter()
471 .filter(|(_, _, _, _, key)| *key)
472 .collect::<Vec<_>>();
473 index_columns.sort_by_key(|(sequence, _, _, _, _)| *sequence);
474 let Some(names) = index_columns
475 .iter()
476 .map(|(_, name, _, _, _)| name.clone())
477 .collect::<Option<Vec<_>>>()
478 else {
479 continue;
480 };
481 if names != target_columns {
482 continue;
483 }
484 let mut columns = Vec::with_capacity(index_columns.len());
485 let mut usable = true;
486 for ((_, _, descending, index_collation, _), name) in index_columns.into_iter().zip(names) {
487 let column = parent_columns
488 .get(&name)
489 .cloned()
490 .ok_or_else(|| rusqlite::Error::InvalidColumnName(name.clone()))?;
491 let index_collation = index_collation
492 .ok_or_else(|| rusqlite::Error::InvalidColumnName(name.clone()))?
493 .to_ascii_uppercase();
494 if index_collation != column.collation {
495 usable = false;
496 }
497 columns.push(CanonicalUniqueKeyColumn {
498 column,
499 index_collation,
500 descending,
501 });
502 }
503 if usable {
504 keys.insert(CanonicalUniqueKey {
505 parent_table: parent_table.to_string(),
506 columns,
507 });
508 }
509 }
510 Ok(keys.into_iter().collect())
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 fn schema() -> rusqlite::Connection {
518 let conn = rusqlite::Connection::open_in_memory().expect("open");
519 conn.execute_batch(
520 "PRAGMA foreign_keys = ON;
521 CREATE TABLE parents (
522 id TEXT PRIMARY KEY,
523 ordinary TEXT DEFAULT 'first',
524 audience TEXT,
525 _updated_at TEXT NOT NULL
526 ) STRICT;
527 CREATE INDEX parents_ordinary ON parents(ordinary);
528 CREATE TABLE children (
529 id TEXT PRIMARY KEY,
530 parent_id TEXT NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
531 ordinary INTEGER,
532 _updated_at TEXT NOT NULL
533 ) STRICT;",
534 )
535 .expect("schema");
536 conn
537 }
538
539 fn declarations() -> Vec<SyncedTable> {
540 vec![
541 SyncedTable::new("children", RowIdentity::IndependentUuid)
542 .inherits_audience_through("parent_id"),
543 SyncedTable::new("parents", RowIdentity::IndependentUuid).scoped_by("audience"),
544 ]
545 }
546
547 #[test]
548 fn canonical_hash_binds_routing_declarations_and_synced_foreign_keys() {
549 let conn = schema();
550 let declarations = declarations();
551 let contract =
552 SyncRoutingContract::from_connection(&conn, &declarations).expect("routing contract");
553 let reversed = SyncRoutingContract::from_connection(
554 &conn,
555 &declarations.iter().cloned().rev().collect::<Vec<_>>(),
556 )
557 .expect("reordered contract");
558 assert_eq!(contract.bytes(), reversed.bytes());
559 assert_eq!(contract.hash(), reversed.hash());
560 assert!(contract.has_scoped_graph());
561 assert_eq!(
562 SyncRoutingContract::from_bytes(contract.bytes()).expect("parse exact contract"),
563 contract,
564 );
565
566 let changed = vec![
567 SyncedTable::new("children", RowIdentity::SharedKey)
568 .inherits_audience_through("parent_id"),
569 declarations[1].clone(),
570 ];
571 assert_ne!(
572 contract.hash(),
573 SyncRoutingContract::from_connection(&conn, &changed)
574 .expect("changed contract")
575 .hash()
576 );
577 }
578
579 #[test]
580 fn ordinary_columns_indexes_defaults_and_local_tables_do_not_change_the_hash() {
581 let conn = schema();
582 let declarations = declarations();
583 let before = SyncRoutingContract::from_connection(&conn, &declarations)
584 .expect("routing contract before ordinary migration");
585 conn.execute_batch(
586 "ALTER TABLE parents ADD COLUMN later TEXT DEFAULT 'second';
587 CREATE INDEX children_ordinary ON children(ordinary);
588 CREATE TABLE local_notes (id TEXT PRIMARY KEY) STRICT;",
589 )
590 .expect("ordinary migration");
591 let after = SyncRoutingContract::from_connection(&conn, &declarations)
592 .expect("routing contract after ordinary migration");
593 assert_eq!(before.bytes(), after.bytes());
594 assert_eq!(before.hash(), after.hash());
595 }
596
597 #[test]
598 fn noncanonical_or_unknown_contract_bytes_are_rejected() {
599 let contract = SyncRoutingContract::from_connection(&schema(), &declarations())
600 .expect("routing contract");
601 let mut value: serde_json::Value =
602 serde_json::from_slice(contract.bytes()).expect("parse contract json");
603 value
604 .as_object_mut()
605 .expect("contract object")
606 .insert("unknown".to_string(), serde_json::Value::Bool(true));
607 assert!(SyncRoutingContract::from_bytes(&serde_json::to_vec(&value).unwrap()).is_err());
608
609 let pretty = serde_json::to_vec_pretty(
610 &serde_json::from_slice::<serde_json::Value>(contract.bytes()).unwrap(),
611 )
612 .unwrap();
613 assert!(SyncRoutingContract::from_bytes(&pretty).is_err());
614 }
615
616 #[test]
617 fn required_column_ordinal_changes_the_contract() {
618 fn contract(parent_columns: &str) -> SyncRoutingContract {
619 let conn = rusqlite::Connection::open_in_memory().expect("open");
620 conn.execute_batch(&format!(
621 "PRAGMA foreign_keys = ON;
622 CREATE TABLE parents ({parent_columns}) STRICT;
623 CREATE TABLE children (
624 id TEXT PRIMARY KEY,
625 parent_id TEXT NOT NULL REFERENCES parents(id),
626 _updated_at TEXT NOT NULL
627 ) STRICT;"
628 ))
629 .expect("schema");
630 SyncRoutingContract::from_connection(&conn, &declarations()).expect("contract")
631 }
632
633 let before = contract(
634 "id TEXT PRIMARY KEY,
635 ordinary TEXT,
636 audience TEXT,
637 _updated_at TEXT NOT NULL",
638 );
639 let after = contract(
640 "ordinary TEXT,
641 id TEXT PRIMARY KEY,
642 audience TEXT,
643 _updated_at TEXT NOT NULL",
644 );
645 assert_ne!(before.hash(), after.hash());
646 }
647
648 #[test]
649 fn routing_column_collation_changes_the_contract() {
650 fn contract(audience: &str) -> SyncRoutingContract {
651 let conn = rusqlite::Connection::open_in_memory().expect("open");
652 conn.execute_batch(&format!(
653 "PRAGMA foreign_keys = ON;
654 CREATE TABLE parents (
655 id TEXT PRIMARY KEY,
656 {audience},
657 _updated_at TEXT NOT NULL
658 ) STRICT;
659 CREATE TABLE children (
660 id TEXT PRIMARY KEY,
661 parent_id TEXT NOT NULL REFERENCES parents(id),
662 _updated_at TEXT NOT NULL
663 ) STRICT;"
664 ))
665 .expect("schema");
666 SyncRoutingContract::from_connection(&conn, &declarations()).expect("contract")
667 }
668
669 let binary = contract("audience TEXT COLLATE BINARY");
670 let no_case = contract("audience TEXT COLLATE NOCASE");
671 assert_ne!(binary.hash(), no_case.hash());
672 }
673
674 #[test]
675 fn synced_foreign_key_to_local_table_is_rejected() {
676 let conn = rusqlite::Connection::open_in_memory().expect("open");
677 conn.execute_batch(
678 "CREATE TABLE local_parents (id TEXT PRIMARY KEY) STRICT;
679 CREATE TABLE children (
680 id TEXT PRIMARY KEY,
681 local_parent_id TEXT NOT NULL REFERENCES local_parents(id),
682 _updated_at TEXT NOT NULL
683 ) STRICT;",
684 )
685 .expect("schema");
686
687 let error = SyncRoutingContract::from_connection(
688 &conn,
689 &[SyncedTable::new("children", RowIdentity::IndependentUuid)],
690 )
691 .expect_err("synced-to-local foreign key must be rejected");
692 assert!(matches!(
693 error,
694 SyncRoutingContractError::UndeclaredForeignKeyTarget {
695 child_table,
696 parent_table,
697 } if child_table == "children" && parent_table == "local_parents"
698 ));
699 }
700
701 #[test]
702 fn non_primary_foreign_key_requires_matching_unique_parent_key() {
703 fn connection(index: &str) -> rusqlite::Connection {
704 let conn = rusqlite::Connection::open_in_memory().expect("open");
705 conn.execute_batch(&format!(
706 "PRAGMA foreign_keys = ON;
707 CREATE TABLE parents (
708 id TEXT PRIMARY KEY,
709 code TEXT COLLATE NOCASE NOT NULL,
710 _updated_at TEXT NOT NULL
711 ) STRICT;
712 {index}
713 CREATE TABLE children (
714 id TEXT PRIMARY KEY,
715 parent_code TEXT NOT NULL,
716 _updated_at TEXT NOT NULL,
717 FOREIGN KEY (parent_code) REFERENCES parents(code)
718 ) STRICT;"
719 ))
720 .expect("schema");
721 conn
722 }
723 let declarations = vec![
724 SyncedTable::new("parents", RowIdentity::IndependentUuid),
725 SyncedTable::new("children", RowIdentity::IndependentUuid),
726 ];
727
728 SyncRoutingContract::from_connection(
729 &connection("CREATE UNIQUE INDEX parents_code ON parents(code COLLATE NOCASE);"),
730 &declarations,
731 )
732 .expect("matching structural unique key");
733 for index in [
734 "",
735 "CREATE UNIQUE INDEX parents_code ON parents(code COLLATE BINARY);",
736 ] {
737 let error = SyncRoutingContract::from_connection(&connection(index), &declarations)
738 .expect_err("missing or changed unique key must be rejected");
739 assert!(matches!(
740 error,
741 SyncRoutingContractError::MissingUniqueParentKey { .. }
742 ));
743 }
744 }
745
746 #[test]
747 fn foreign_key_declaration_order_does_not_change_the_contract() {
748 fn contract(foreign_keys: &str) -> SyncRoutingContract {
749 let conn = rusqlite::Connection::open_in_memory().expect("open");
750 conn.execute_batch(&format!(
751 "CREATE TABLE left_parents (
752 id TEXT PRIMARY KEY,
753 _updated_at TEXT NOT NULL
754 ) STRICT;
755 CREATE TABLE right_parents (
756 id TEXT PRIMARY KEY,
757 _updated_at TEXT NOT NULL
758 ) STRICT;
759 CREATE TABLE children (
760 id TEXT PRIMARY KEY,
761 left_id TEXT NOT NULL,
762 right_id TEXT NOT NULL,
763 _updated_at TEXT NOT NULL,
764 {foreign_keys}
765 ) STRICT;"
766 ))
767 .expect("schema");
768 SyncRoutingContract::from_connection(
769 &conn,
770 &[
771 SyncedTable::new("left_parents", RowIdentity::IndependentUuid),
772 SyncedTable::new("right_parents", RowIdentity::IndependentUuid),
773 SyncedTable::new("children", RowIdentity::IndependentUuid),
774 ],
775 )
776 .expect("contract")
777 }
778
779 let left_then_right = contract(
780 "FOREIGN KEY (left_id) REFERENCES left_parents(id) ON DELETE CASCADE,
781 FOREIGN KEY (right_id) REFERENCES right_parents(id) ON UPDATE CASCADE",
782 );
783 let right_then_left = contract(
784 "FOREIGN KEY (right_id) REFERENCES right_parents(id) ON UPDATE CASCADE,
785 FOREIGN KEY (left_id) REFERENCES left_parents(id) ON DELETE CASCADE",
786 );
787 assert_eq!(left_then_right.bytes(), right_then_left.bytes());
788 }
789}