Pixel Pedals of Tomakomai

北海道苫小牧市出身の初老の日常

Windows 上の Rust で SQLite

Rust でちょっとしたツールを書いているのだが、読み込む JSON が巨大過ぎてデータの一部分だけを使うにしても毎回 1 分程度読み込みに時間がかかってしまうので、パース結果だけを使いやすい形で保存しておきたくなった。もちろん、 JSON を分割して保存しておくだけでも効果はあるのだが、整形し直すのであれば JSON に拘る必要もない。

現在 Windows で開発しているので、 Windows でも動くことを要件とする。もちろん、 WSL2 で動かせばいいのだけど、せっかく cross platform な Rust で開発しているのだから、それだけのために linux の世界に閉じこもるのもナンセンスに思える 1 。それと、外部ライブラリが必要となる crate も避けたい。 Windows PC は複数あるので、このツールを動かすためだけに各 PC で1台1台ライブラリを揃えるのは億劫だ。

ChatGPT に相談したところ、初めは surrealdb を勧められた。面白そうではあるのだけど、 JSON を整形して保存するだけにしてはちょっと大げさ過ぎやしないか。そう思いながらもビルドしてみたのだが、 Windows 上ではエラーとなってサンプルが動かなかったので早々に諦めた。もちろん、インストール手順に従って最初に surrealdb をインストールすれば動くのだろうが、それは今回の要件に反する。

次に勧められたのは sled で、 pure-rust だし今回の要件としては NoSQL で十分なのでコイツを使おうと思っていたのだけど、そんな矢先に Qiita でこんな記事を見つけた。

kawasin73.hatenablog.com

SQLite が使えるのであれば、 sqlite3 コマンドラインでクエリできるし、それに越したことはない。試しにビルドをしてみたが、 Windows ではエラーでビルドできないようだ。原因はここ。 std::os::unix::fs::FileExt を使っている。

github.com

幸い、 fs::FileExt には windows 用の類似のインタフェースもあるので、そちらでお茶を濁すとビルドできた。

diff --git a/src/lib.rs b/src/lib.rs
index c7c0461..6ce5991 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -32,7 +32,7 @@ use std::cell::Cell;
 use std::cell::RefCell;
 use std::fmt::Display;
 use std::fs::OpenOptions;
-use std::os::unix::fs::FileExt;
+use std::os::windows::fs::FileExt;
 use std::path::Path;
 
 use anyhow::bail;
@@ -171,7 +171,7 @@ impl Connection {
             .open(filename)
             .with_context(|| format!("failed to open file: {:?}", filename))?;
         let mut buf = [0; DATABASE_HEADER_SIZE];
-        file.read_exact_at(&mut buf, 0)?;
+        file.seek_read(&mut buf, 0)?;
         let header = DatabaseHeader::from(&buf);
         header
             .validate()
diff --git a/src/pager.rs b/src/pager.rs
index 0d7c565..fce71a0 100644
--- a/src/pager.rs
+++ b/src/pager.rs
@@ -26,7 +26,7 @@ use std::io;
 use std::num::NonZeroU32;
 use std::ops::Deref;
 use std::ops::DerefMut;
-use std::os::unix::fs::FileExt;
+use std::os::windows::fs::FileExt;
 use std::rc::Rc;
 
 use crate::header::DatabaseHeader;
@@ -224,7 +224,7 @@ impl Pager {
             let (page1, is_new) = self.cache.get_page(PAGE_ID_1);
             let mut page1 = if is_new {
                 let mut page1 = page1.borrow_mut();
-                self.file.read_exact_at(&mut page1.buf, 0)?;
+                self.file.seek_read(&mut page1.buf, 0)?;
                 page1
             } else {
                 page1.try_borrow_mut()?
@@ -238,7 +238,7 @@ impl Pager {
             let mut trunk_page = if is_new {
                 let mut trunk_page = trunk_page.borrow_mut();
                 self.file
-                    .read_exact_at(&mut trunk_page.buf, self.page_offset(first_page_id))?;
+                    .seek_read(&mut trunk_page.buf, self.page_offset(first_page_id))?;
                 trunk_page
             } else {
                 trunk_page.try_borrow_mut()?
@@ -293,7 +293,7 @@ impl Pager {
         if is_new {
             let mut raw_page = page.borrow_mut();
             self.file
-                .read_exact_at(&mut raw_page.buf, self.page_offset(page_id))?;
+                .seek_read(&mut raw_page.buf, self.page_offset(page_id))?;
         }
         let header_offset = if page_id == PAGE_ID_1 {
             DATABASE_HEADER_SIZE
@@ -327,7 +327,7 @@ impl Pager {
         let (page1, is_new) = self.cache.get_page(PAGE_ID_1);
         let mut page1 = if is_new {
             let mut page1 = page1.borrow_mut();
-            self.file.read_exact_at(&mut page1.buf, 0)?;
+            self.file.seek_read(&mut page1.buf, 0)?;
             page1
         } else {
             page1.try_borrow_mut()?
@@ -348,7 +348,7 @@ impl Pager {
             let mut trunk_page = if is_new {
                 let mut trunk_page = trunk_page.borrow_mut();
                 self.file
-                    .read_exact_at(&mut trunk_page.buf, self.page_offset(first_page_id))?;
+                    .seek_read(&mut trunk_page.buf, self.page_offset(first_page_id))?;
                 trunk_page
             } else {
                 trunk_page.try_borrow_mut()?
@@ -407,7 +407,7 @@ impl Pager {
             let raw_page = page.try_borrow()?;
             if raw_page.is_dirty {
                 self.file
-                    .write_all_at(&raw_page.buf, self.page_offset(*page_id))?;
+                    .seek_write(&raw_page.buf, self.page_offset(*page_id))?;
                 drop(raw_page);
                 page.try_borrow_mut()?.is_dirty = false;
             }
@@ -431,7 +431,7 @@ impl Pager {
             if is_new {
                 let mut page1 = page1.borrow_mut();
                 self.file
-                    .read_exact_at(&mut page1.buf, 0)
+                    .seek_read(&mut page1.buf, 0)
                     .expect("read page 1 must succeed");
             }
             let buffer = &page1.borrow().buf;
@@ -562,12 +562,12 @@ mod tests {
     #[test]
     fn test_new() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[0_u8; 4096], 0).unwrap();
+        file.seek_write(&[0_u8; 4096], 0).unwrap();
         let pager = Pager::new(file, 1, 4096, 4096, None, 0).unwrap();
         assert_eq!(pager.num_pages(), 1);
 
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[0_u8; 4096 * 2], 0).unwrap();
+        file.seek_write(&[0_u8; 4096 * 2], 0).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
         assert_eq!(pager.num_pages(), 2);
 
@@ -583,8 +583,8 @@ mod tests {
     #[test]
     fn test_get_page() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -606,8 +606,8 @@ mod tests {
     #[test]
     fn test_make_page_mut() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -637,7 +637,7 @@ mod tests {
     #[test]
     fn test_make_page_mut_failure() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
         let pager = Pager::new(file, 1, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -660,8 +660,8 @@ mod tests {
     #[test]
     fn test_swap_page_buffer() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
         let page_id_2 = PageId::new(2).unwrap();
 
@@ -694,8 +694,8 @@ mod tests {
     #[test]
     fn test_commit() {
         let file = tempfile::NamedTempFile::new().unwrap();
-        file.as_file().write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.as_file().write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.as_file().seek_write(&[1_u8; 4096], 0).unwrap();
+        file.as_file().seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 2, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -712,7 +712,7 @@ mod tests {
         pager.commit().unwrap();
 
         let mut buf = [0; 4096 * 2];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [3_u8; 4096]);
         assert_eq!(buf[4096..], [4_u8; 4096]);
     }
@@ -720,7 +720,7 @@ mod tests {
     #[test]
     fn test_commit_failure() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
         let pager = Pager::new(file, 1, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -742,7 +742,7 @@ mod tests {
             DatabaseHeaderMut::from((&mut original[..DATABASE_HEADER_SIZE]).try_into().unwrap());
         header.set_first_freelist_trunk_page_id(PageId::new(2));
         header.set_n_freelist_pages(1);
-        file.as_file().write_all_at(&original, 0).unwrap();
+        file.as_file().seek_write(&original, 0).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 1, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -759,7 +759,7 @@ mod tests {
             original.as_slice()
         );
         let mut buf = [0; 4096];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf, original);
 
         assert_eq!(pager.first_freelist_trunk_page_id.get(), PageId::new(2));
@@ -808,7 +808,7 @@ mod tests {
         assert_eq!(file.as_file().metadata().unwrap().len(), 4096 * 2);
 
         let mut buf = [0; 4096 * 2];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [10_u8; 4096]);
         assert_eq!(buf[4096..], [11_u8; 4096]);
     }
@@ -816,8 +816,8 @@ mod tests {
     #[test]
     fn test_allocate_page_non_empty_file() {
         let file = tempfile::NamedTempFile::new().unwrap();
-        file.as_file().write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.as_file().write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.as_file().seek_write(&[1_u8; 4096], 0).unwrap();
+        file.as_file().seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 2, 4096, 4096, None, 0).unwrap();
 
         let (page_id, page) = pager.allocate_page().unwrap();
@@ -834,7 +834,7 @@ mod tests {
         assert_eq!(file.as_file().metadata().unwrap().len(), 4096 * 3);
 
         let mut buf = [0; 4096 * 3];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [1_u8; 4096]);
         assert_eq!(buf[4096..4096 * 2], [2_u8; 4096]);
         assert_eq!(buf[4096 * 2..], [3_u8; 4096]);
@@ -844,7 +844,7 @@ mod tests {
     fn test_allocate_page_failure() {
         let file = tempfile::NamedTempFile::new().unwrap();
         file.as_file()
-            .write_all_at(&[1; 4096], (MAX_PAGE_ID as u64 - 1) * 4096)
+            .seek_write(&[1; 4096], (MAX_PAGE_ID as u64 - 1) * 4096)
             .unwrap();
         let pager = Pager::new(file.reopen().unwrap(), MAX_PAGE_ID, 4096, 4096, None, 0).unwrap();
         assert_eq!(pager.num_pages(), MAX_PAGE_ID);
@@ -858,8 +858,8 @@ mod tests {
     #[test]
     fn test_allocate_page_abort() {
         let file = tempfile::NamedTempFile::new().unwrap();
-        file.as_file().write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.as_file().write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.as_file().seek_write(&[1_u8; 4096], 0).unwrap();
+        file.as_file().seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 2, 4096, 4096, None, 0).unwrap();
 
         let (page_id, page) = pager.allocate_page().unwrap();
@@ -881,7 +881,7 @@ mod tests {
         assert_eq!(file.as_file().metadata().unwrap().len(), 4096 * 2);
 
         let mut buf = [0; 4096 * 2];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [1_u8; 4096]);
         assert_eq!(buf[4096..4096 * 2], [2_u8; 4096]);
     }
@@ -905,14 +905,14 @@ mod tests {
         );
         header.set_first_freelist_trunk_page_id(PageId::new(2));
         header.set_n_freelist_pages(5);
-        file.as_file().write_all_at(&header_buf, 0).unwrap();
+        file.as_file().seek_write(&header_buf, 0).unwrap();
         file.as_file()
-            .write_all_at(&[0, 0, 0, 3, 0, 0, 0, 2, 0, 0, 0, 4, 0, 0, 0, 5], 4096)
+            .seek_write(&[0, 0, 0, 3, 0, 0, 0, 2, 0, 0, 0, 4, 0, 0, 0, 5], 4096)
             .unwrap();
         file.as_file()
-            .write_all_at(&[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 6], 4096 * 2)
+            .seek_write(&[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 6], 4096 * 2)
             .unwrap();
-        file.as_file().write_all_at(&[2; 4096], 4096 * 6).unwrap();
+        file.as_file().seek_write(&[2; 4096], 4096 * 6).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 7, 4096, 4096, PageId::new(2), 5).unwrap();
 
         let (page_id, _) = pager.allocate_page().unwrap();
@@ -948,12 +948,12 @@ mod tests {
         );
         header.set_first_freelist_trunk_page_id(PageId::new(2));
         header.set_n_freelist_pages(127);
-        file.as_file().write_all_at(&header_buf, 0).unwrap();
+        file.as_file().seek_write(&header_buf, 0).unwrap();
         file.as_file()
-            .write_all_at(&[0, 0, 0, 0, 0, 0, 0, 126], 512)
+            .seek_write(&[0, 0, 0, 0, 0, 0, 0, 126], 512)
             .unwrap();
         file.as_file()
-            .write_all_at(
+            .seek_write(
                 &[
                     0, 0, 0, 10, 0, 0, 0, 11, 0, 0, 0, 12, 0, 0, 0, 13, 0, 0, 0, 14, 0, 0, 0, 15,
                     0, 0, 0, 16, 0, 0, 0, 17, 0, 0, 0, 18,
@@ -961,7 +961,7 @@ mod tests {
                 1024 - 36,
             )
             .unwrap();
-        file.as_file().write_all_at(&[2; 512], 512 * 17).unwrap();
+        file.as_file().seek_write(&[2; 512], 512 * 17).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 18, 512, 512, PageId::new(2), 127).unwrap();
 
         let (page_id, _) = pager.allocate_page().unwrap();
diff --git a/src/test_utils.rs b/src/test_utils.rs
index c37d3b4..fb9e0de 100644
--- a/src/test_utils.rs
+++ b/src/test_utils.rs
@@ -14,7 +14,7 @@
 
 use std::fmt::Write;
 use std::fs::File;
-use std::os::unix::fs::FileExt;
+use std::os::windows::fs::FileExt;
 use std::path::Path;
 
 use tempfile::NamedTempFile;
@@ -47,7 +47,7 @@ pub fn create_sqlite_database(queries: &[&str]) -> NamedTempFile {
 
 pub fn create_pager(file: File) -> anyhow::Result<Pager> {
     let mut header_buf = [0_u8; DATABASE_HEADER_SIZE];
-    file.read_exact_at(&mut header_buf, 0)?;
+    file.seek_read(&mut header_buf, 0)?;
     let header = DatabaseHeader::from(&header_buf);
     Ok(Pager::new(
         file,
@@ -61,7 +61,7 @@ pub fn create_pager(file: File) -> anyhow::Result<Pager> {
 
 pub fn create_empty_pager(file_content: &[u8], pagesize: u32, usable_size: u32) -> Pager {
     let file = NamedTempFile::new().unwrap();
-    file.as_file().write_all_at(file_content, 0).unwrap();
+    file.as_file().seek_write(file_content, 0).unwrap();
     Pager::new(
         file.as_file().try_clone().unwrap(),
         file_content.len() as u32 / pagesize,
@@ -75,7 +75,7 @@ pub fn create_empty_pager(file_content: &[u8], pagesize: u32, usable_size: u32)
 
 pub fn load_btree_context(file: &File) -> anyhow::Result<BtreeContext> {
     let mut header_buf = [0_u8; DATABASE_HEADER_SIZE];
-    file.read_exact_at(&mut header_buf, 0)?;
+    file.seek_read(&mut header_buf, 0)?;
     let header = DatabaseHeader::from(&header_buf);
     Ok(BtreeContext::new(
         header.pagesize() - header.reserved() as u32,

よし、使おう、と思ったのだがこの crate 、実は DB の初期化がまだ実装されていない。単体テストが通らなくてなぜかと思っていたのだが、よく読むと rusqlite という別のクレートを使っており、こちらは sqlite3 がインストールされていないとコンパイルが通らない。

github.com

ところで、この rusqlite というのは一体どんな crate なのだろう。 crates.io を見てみると、こんな記述がある。

features = ["bundled"]

これは、 crate にバンドルされた SQLite3 を使ってコンパイルするもので、今回の要件に完全に合致する。試してみると Windows 上でもそのままビルドできた。当たり前ではあるが、 rusqlite で生成した DB ファイルは WSL2 上の sqlite3 コマンドで読み書きできる。素晴らしい。

余談が 2 つある。まず、 rusqlite だが、この crate は libsqlite3-sys を使っている( github の同一リポジトリに存在する)のだが、 rusqlite のダウンロード数が 15M なのに対し、 libsqlite3-sys が 25M と大きく数がかけ離れている。これは libsqlite3-sys が他のクレートからも直接依存されているからで、 SQLxdiesel からの依存がある。それだけ信頼性のある crate ということだろう。

2 つ目の余談。知らなかったのだが、 SQLite3 はデフォルトでは LIKE の前方一致検索で index を使わない。これはデフォルトでは case insensitive なマッチを行うためで、

PRAGMA case_sensitive_like=ON;

とするだけで index が使われるようになる。一見、全然関連性のない設定なので、裏事情まで知らないと意味がわからない挙動の変化に思える。が、この話は 2006年のblog で見られるような内容なので、お前は今さら何を言っているんだ的な話なのであろう。


  1. 一方で、例えば HaskellPerl の主戦場は linux なわけで、そういうときは WSL2 に籠もれば良い。