sqlite.rs 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. #![allow(missing_docs)]
  2. #![cfg(feature = "sqlite")]
  3. use kuatia_storage::store::{AccountStore, PostingStore};
  4. use kuatia_storage_sql::SqlStore;
  5. use kuatia_types::*;
  6. use sqlx::{Any, Pool, Row};
  7. async fn new_pool() -> Pool<Any> {
  8. sqlx::any::install_default_drivers();
  9. sqlx::any::AnyPoolOptions::new()
  10. .max_connections(1)
  11. .connect("sqlite::memory:")
  12. .await
  13. .unwrap()
  14. }
  15. async fn new_store() -> SqlStore {
  16. let store = SqlStore::new(new_pool().await);
  17. store.migrate().await.unwrap();
  18. store
  19. }
  20. kuatia_storage::store_tests!(new_store);
  21. /// The point of the schema: no column holds opaque binary. A content-addressed
  22. /// id is stored as lower-case hex text, and a structured payload as readable
  23. /// JSON text, so a row can be audited in a plain SQL client.
  24. #[tokio::test]
  25. async fn columns_store_hex_ids_and_json_text() {
  26. let pool = new_pool().await;
  27. let store = SqlStore::new(pool.clone());
  28. store.migrate().await.unwrap();
  29. let account = Account {
  30. id: AccountId::new(1),
  31. version: 1,
  32. policy: AccountPolicy::NoOverdraft,
  33. flags: AccountFlags::empty(),
  34. book: BookId(0),
  35. user_data: UserData::default(),
  36. metadata: std::collections::BTreeMap::new(),
  37. };
  38. store.create_account(account).await.unwrap();
  39. let tid = EnvelopeId([0xab; 32]);
  40. let posting = Posting::new(
  41. PostingId {
  42. transfer: tid,
  43. index: 0,
  44. },
  45. AccountId::new(1),
  46. AssetId::new(1),
  47. Cent::from(100),
  48. );
  49. store.insert_postings(&[posting]).await.unwrap();
  50. // The 32-byte transfer id is stored as its 64-char lower-case hex form.
  51. let row = sqlx::query("SELECT transfer_id FROM postings")
  52. .fetch_one(&pool)
  53. .await
  54. .unwrap();
  55. let transfer_id: String = row.try_get("transfer_id").unwrap();
  56. assert_eq!(transfer_id, "ab".repeat(32));
  57. // The account payload is readable JSON text, not a blob.
  58. let row = sqlx::query("SELECT user_data FROM accounts")
  59. .fetch_one(&pool)
  60. .await
  61. .unwrap();
  62. let user_data: String = row.try_get("user_data").unwrap();
  63. assert!(
  64. serde_json::from_str::<serde_json::Value>(&user_data).is_ok(),
  65. "user_data should be JSON text, got: {user_data}"
  66. );
  67. }
  68. /// The 002 migration adds the subaccount column and a subaccount account/posting
  69. /// round-trips through the schema, kept distinct from the main account.
  70. #[tokio::test]
  71. async fn subaccount_columns_round_trip() {
  72. let store = new_store().await;
  73. let sub = AccountId::with_sub(1, 7);
  74. store
  75. .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
  76. .await
  77. .unwrap();
  78. // The main account (1, 0) is a separate record.
  79. store
  80. .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
  81. .await
  82. .unwrap();
  83. let got = store.get_account(&sub).await.unwrap();
  84. assert_eq!(got.id, sub);
  85. let posting = Posting::new(
  86. PostingId {
  87. transfer: EnvelopeId([0xcd; 32]),
  88. index: 0,
  89. },
  90. sub,
  91. AssetId::new(1),
  92. Cent::from(500),
  93. );
  94. store.insert_postings(&[posting]).await.unwrap();
  95. // Filtering by the subaccount returns it; the main account holds nothing.
  96. let by_sub = store
  97. .get_postings_by_account(1, Some(7), None, None)
  98. .await
  99. .unwrap();
  100. assert_eq!(by_sub.len(), 1);
  101. assert_eq!(by_sub[0].owner, sub);
  102. let by_main = store
  103. .get_postings_by_account(1, Some(0), None, None)
  104. .await
  105. .unwrap();
  106. assert!(by_main.is_empty());
  107. }
  108. /// A database created under 001 (no subaccount column) upgrades in place: the
  109. /// 002 migration rebuilds the tables and existing rows default to subaccount 0,
  110. /// the main account.
  111. #[tokio::test]
  112. async fn migration_upgrades_existing_rows_to_main_account() {
  113. let pool = new_pool().await;
  114. // Pre-002 schema: the tables 002 rebuilds, in their old (no-subaccount) shape.
  115. sqlx::query(
  116. "CREATE TABLE accounts (id BIGINT NOT NULL, version BIGINT NOT NULL, policy TEXT NOT NULL, flags INTEGER NOT NULL, book BIGINT NOT NULL, user_data TEXT NOT NULL, metadata TEXT NOT NULL, PRIMARY KEY (id, version))",
  117. )
  118. .execute(&pool)
  119. .await
  120. .unwrap();
  121. sqlx::query(
  122. "CREATE TABLE postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, asset INTEGER NOT NULL, value TEXT NOT NULL, status SMALLINT NOT NULL, reservation BIGINT, PRIMARY KEY (transfer_id, idx))",
  123. )
  124. .execute(&pool)
  125. .await
  126. .unwrap();
  127. sqlx::query("CREATE INDEX idx_postings_owner ON postings(owner, asset, status)")
  128. .execute(&pool)
  129. .await
  130. .unwrap();
  131. sqlx::query(
  132. "CREATE TABLE transfer_accounts (transfer_id TEXT NOT NULL, account_id BIGINT NOT NULL, PRIMARY KEY (transfer_id, account_id))",
  133. )
  134. .execute(&pool)
  135. .await
  136. .unwrap();
  137. sqlx::query("CREATE INDEX idx_xfer_acct ON transfer_accounts(account_id)")
  138. .execute(&pool)
  139. .await
  140. .unwrap();
  141. let user_data = serde_json::to_string(&UserData::default()).unwrap();
  142. let metadata =
  143. serde_json::to_string(&std::collections::BTreeMap::<String, String>::new()).unwrap();
  144. sqlx::query(
  145. "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES (5, 1, '\"NoOverdraft\"', 0, 0, $1, $2)",
  146. )
  147. .bind(user_data)
  148. .bind(metadata)
  149. .execute(&pool)
  150. .await
  151. .unwrap();
  152. // Record 001 as applied so migrate() only runs 002 against this schema.
  153. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  154. .execute(&pool)
  155. .await
  156. .unwrap();
  157. sqlx::query("INSERT INTO _migrations (name) VALUES ('001_init')")
  158. .execute(&pool)
  159. .await
  160. .unwrap();
  161. let store = SqlStore::new(pool);
  162. store.migrate().await.unwrap();
  163. // The pre-existing account is now the main account (subaccount 0).
  164. let got = store.get_account(&AccountId::new(5)).await.unwrap();
  165. assert_eq!(got.id, AccountId::new(5));
  166. assert!(got.id.is_main());
  167. }
  168. /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
  169. #[tokio::test]
  170. async fn migrate_is_idempotent() {
  171. sqlx::any::install_default_drivers();
  172. let pool = sqlx::any::AnyPoolOptions::new()
  173. .max_connections(1)
  174. .connect("sqlite::memory:")
  175. .await
  176. .unwrap();
  177. let store = SqlStore::new(pool);
  178. store.migrate().await.unwrap();
  179. store.migrate().await.unwrap();
  180. store.migrate().await.unwrap();
  181. }