Add few benchmarks for the storage backends

This commit is contained in:
Hassan Abedi 2026-06-04 12:47:47 +02:00
parent b572161142
commit 4e055f34e6
45 changed files with 5352 additions and 1958 deletions

601
Cargo.lock generated
View File

@ -2,6 +2,18 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -11,6 +23,18 @@ dependencies = [
"memchr",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.102"
@ -38,6 +62,12 @@ dependencies = [
"critical-section",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bincode"
version = "1.3.3"
@ -47,12 +77,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@ -76,6 +100,12 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -88,6 +118,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6236364b88b9b6d0bc181ba374cf1ab55ba3ef97a1cb6f8cddad48a273767fb5"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.63"
@ -110,6 +146,58 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@ -150,12 +238,39 @@ dependencies = [
]
[[package]]
name = "crc32fast"
version = "1.5.0"
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"cfg-if",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
@ -164,6 +279,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
@ -198,6 +323,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "dashmap"
version = "6.2.1"
@ -209,7 +340,7 @@ dependencies = [
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core 0.9.12",
"parking_lot_core",
]
[[package]]
@ -238,6 +369,12 @@ dependencies = [
"phf",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "embedded-io"
version = "0.4.0"
@ -290,6 +427,18 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
@ -335,22 +484,27 @@ dependencies = [
]
[[package]]
name = "fs2"
version = "0.4.3"
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "fxhash"
version = "0.2.1"
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"byteorder",
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
@ -385,6 +539,7 @@ version = "0.1.0"
dependencies = [
"geomerge",
"serde_json",
"storage",
]
[[package]]
@ -406,6 +561,17 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hash32"
version = "0.2.1"
@ -420,6 +586,9 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
@ -442,6 +611,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heapless"
version = "0.7.17"
@ -468,7 +646,7 @@ version = "0.20.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"byteorder",
"heed-traits",
"heed-types",
@ -500,6 +678,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@ -645,15 +829,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "interval-heap"
version = "0.0.5"
@ -663,12 +838,44 @@ dependencies = [
"compare",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -693,6 +900,17 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -791,7 +1009,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
@ -806,12 +1024,27 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
@ -822,31 +1055,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "parking_lot"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core 0.8.6",
]
[[package]]
name = "parking_lot_core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall 0.2.16",
"smallvec",
"winapi",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
@ -855,7 +1063,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@ -932,6 +1140,40 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "postcard"
version = "1.1.3"
@ -977,19 +1219,7 @@ dependencies = [
name = "query-ops"
version = "0.1.0"
dependencies = [
"query-storage",
]
[[package]]
name = "query-storage"
version = "0.1.0"
dependencies = [
"fjall",
"geomerge",
"heed",
"redb",
"sled",
"tempfile",
"storage",
]
[[package]]
@ -1042,6 +1272,26 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redb"
version = "2.6.3"
@ -1051,22 +1301,25 @@ dependencies = [
"libc",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
@ -1086,6 +1339,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@ -1107,20 +1374,26 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustyline"
version = "18.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"cfg-if",
"clipboard-win",
"home",
@ -1135,6 +1408,15 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1224,20 +1506,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "sled"
version = "0.34.7"
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
dependencies = [
"crc32fast",
"crossbeam-epoch",
"crossbeam-utils",
"fs2",
"fxhash",
"libc",
"log",
"parking_lot",
]
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
@ -1266,6 +1538,21 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ae9eec00137a8eed469fb4148acd9fc6ac8c3f9b110f52cd34698c8b5bfa0e"
[[package]]
name = "storage"
version = "0.1.0"
dependencies = [
"criterion",
"fjall",
"geomerge",
"heed",
"redb",
"rusqlite",
"serde_json",
"smallvec",
"tempfile",
]
[[package]]
name = "syn"
version = "2.0.117"
@ -1349,6 +1636,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tracing"
version = "0.1.44"
@ -1487,6 +1784,28 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
@ -1505,6 +1824,51 @@ dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
@ -1533,12 +1897,22 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -1555,6 +1929,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@ -1640,7 +2023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"bitflags",
"indexmap",
"log",
"serde",
@ -1705,6 +2088,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View File

@ -42,15 +42,31 @@ lint: ## Run linters across every crate under crates/
fi
.PHONY: test
test: ## Run tests across all targets and features
test: ## Run unit, integration, and doc tests across all features (no benches)
@if [ -z "$(HAS_CARGO)" ]; then \
echo "No Cargo.toml found. Skipping tests."; \
else \
cargo test --all-targets --all-features; \
cargo test --all-features; \
fi
.PHONY: bench
bench: ## Run benchmarks across all features
@if [ -z "$(HAS_CARGO)" ]; then \
echo "No Cargo.toml found. Skipping benchmarks."; \
else \
cargo bench --all-features; \
fi
.PHONY: bench-check
bench-check: ## Type-check benchmark code without running it
@if [ -z "$(HAS_CARGO)" ]; then \
echo "No Cargo.toml found. Skipping bench check."; \
else \
cargo check --benches --all-features --quiet; \
fi
.PHONY: check
check: format-check lint test ## Run all checks (format-check, lint, test)
check: format-check lint test bench-check ## Run all checks (format-check, lint, test, bench-check)
.PHONY: clean
clean: ## Remove build output

View File

@ -10,4 +10,5 @@ workspace = true
[dependencies]
geomerge = { path = "../../external/geomerge/crates/geomerge" }
storage = { path = "../storage", features = ["geomerge"] }
serde_json = "1"

View File

@ -58,21 +58,26 @@ flat_theory [label = <<table border="0" cellborder="0" cellspacing="0" cellpaddi
}
subgraph cluster_store {
label = "Store and Transaction"
label = "Storage and Transaction"
style = "dashed"
color = "#4CAF50"
fontcolor = "#388E3C"
margin = 14
build_store [label = "Store::try_from_theory", fillcolor = "#E8F5E9", color = "#4CAF50"]
build_store [label = "GeomergeStorage::from_theory\n(Store::try_from_theory)", fillcolor = "#E8F5E9", color = "#4CAF50"]
transact [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>add_paths_data (transact)</b></td></tr>
<tr><td align="left" balign="left">• append Graphs rows</td></tr>
<tr><td align="left" balign="left">• append G0, G1 rows</td></tr>
<tr><td align="left" balign="left">• append G.V vertices</td></tr>
<tr><td align="left" balign="left">• append G.E edge</td></tr>
<tr><td align="center"><b>add_paths_data (tx.insert ×7)</b></td></tr>
<tr><td align="left" balign="left">• insert Graphs rows</td></tr>
<tr><td align="left" balign="left">• insert G0, G1 rows</td></tr>
<tr><td align="left" balign="left">• insert G.V vertices</td></tr>
<tr><td align="left" balign="left">• insert G.E edge</td></tr>
<tr><td align="left" balign="left">• pending RowIds reused as FKs</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50", shape = box]
validate [label = "Law Validation\n(append_row_validated)", fillcolor = "#E8F5E9", color = "#4CAF50"]
assert_edge [label = "assert_edge_was_stored\n(row count, cells)", fillcolor = "#E8F5E9", color = "#4CAF50"]
commit [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>tx.commit()</b></td></tr>
<tr><td align="left" balign="left">• law validation</td></tr>
<tr><td align="left" balign="left">• CommittedTx resolves pending RowIds</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50", shape = box]
assert_edge [label = "assert_edge_was_stored\n(storage.scan(G.E))", fillcolor = "#E8F5E9", color = "#4CAF50"]
}
subgraph cluster_persist {
@ -114,9 +119,8 @@ build_store -> transact [color = "#4CAF50"]
fixture_rows -> transact [style = "dashed", color = "#2196F3"]
// Transaction internals
transact -> validate [color = "#4CAF50"]
validate -> transact [style = "dashed", label = "row ids", color = "#4CAF50"]
transact -> assert_edge [color = "#4CAF50"]
transact -> commit [color = "#4CAF50"]
commit -> assert_edge [color = "#4CAF50"]
// Into persistence
assert_edge -> dump_before [color = "#FF9800"]

View File

@ -1,247 +1,385 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.2.1 (0)
-->
<!-- Title: GeomergeDemoWorkflow Pages: 1 -->
<svg width="2601pt" height="411pt"
viewBox="0.00 0.00 2600.75 411.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 407)">
<title>GeomergeDemoWorkflow</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-407 2596.75,-407 2596.75,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_inputs</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2" points="8,-8 8,-223 202.5,-223 202.5,-8 8,-8"/>
<text text-anchor="middle" x="105.25" y="-205.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#555555">Inputs</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_demo</title>
<path fill="#fafafa" stroke="#666666" d="M284.75,-35C284.75,-35 2155.25,-35 2155.25,-35 2161.25,-35 2167.25,-41 2167.25,-47 2167.25,-47 2167.25,-301 2167.25,-301 2167.25,-307 2161.25,-313 2155.25,-313 2155.25,-313 284.75,-313 284.75,-313 278.75,-313 272.75,-307 272.75,-301 272.75,-301 272.75,-47 272.75,-47 272.75,-41 278.75,-35 284.75,-35"/>
<text text-anchor="middle" x="1220" y="-295.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#333333">geomerge&#45;demo (run_demo)</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_loading</title>
<polygon fill="#fafafa" stroke="#9c27b0" stroke-dasharray="5,2" points="292.75,-113 292.75,-264 609.25,-264 609.25,-113 292.75,-113"/>
<text text-anchor="middle" x="451" y="-246.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#7b1fa2">Theory Loading</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_store</title>
<polygon fill="#fafafa" stroke="#4caf50" stroke-dasharray="5,2" points="648.25,-55 648.25,-264 1350.5,-264 1350.5,-55 648.25,-55"/>
<text text-anchor="middle" x="999.38" y="-246.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#388e3c">Store and Transaction</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_persist</title>
<polygon fill="#fafafa" stroke="#ff9800" stroke-dasharray="5,2" points="1389.5,-63 1389.5,-253 2147.25,-253 2147.25,-63 1389.5,-63"/>
<text text-anchor="middle" x="1768.38" y="-235.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#f57c00">Persistence Round Trip</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_report</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2" points="2191.25,-178 2191.25,-395 2584.75,-395 2584.75,-178 2191.25,-178"/>
<text text-anchor="middle" x="2388" y="-377.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#555555">Report</text>
</g>
<!-- paths_schema -->
<g id="node1" class="node">
<title>paths_schema</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5" d="M159,-176C159,-176 51.5,-176 51.5,-176 45.5,-176 39.5,-170 39.5,-164 39.5,-164 39.5,-138 39.5,-138 39.5,-132 45.5,-126 51.5,-126 51.5,-126 159,-126 159,-126 165,-126 171,-132 171,-138 171,-138 171,-164 171,-164 171,-170 165,-176 159,-176"/>
<text text-anchor="middle" x="105.25" y="-158.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">paths.json</text>
<text text-anchor="middle" x="105.25" y="-137.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(compiled schema)</text>
</g>
<!-- load_theory -->
<g id="node3" class="node">
<title>load_theory</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M422.5,-188C422.5,-188 318.75,-188 318.75,-188 312.75,-188 306.75,-182 306.75,-176 306.75,-176 306.75,-150 306.75,-150 306.75,-144 312.75,-138 318.75,-138 318.75,-138 422.5,-138 422.5,-138 428.5,-138 434.5,-144 434.5,-150 434.5,-150 434.5,-176 434.5,-176 434.5,-182 428.5,-188 422.5,-188"/>
<text text-anchor="middle" x="370.62" y="-170.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">load_paths_theory</text>
<text text-anchor="middle" x="370.62" y="-149.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(serde_json)</text>
</g>
<!-- paths_schema&#45;&gt;load_theory -->
<g id="edge1" class="edge">
<title>paths_schema&#45;&gt;load_theory</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M171.35,-153.97C208.77,-155.67 255.91,-157.82 294.83,-159.59"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2" points="294.67,-163.09 304.82,-160.05 294.99,-156.1 294.67,-163.09"/>
<text text-anchor="middle" x="241.12" y="-163.02" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">include_str!</text>
</g>
<!-- fixture_rows -->
<g id="node2" class="node">
<title>fixture_rows</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5" d="M172.5,-76C172.5,-76 38,-76 38,-76 32,-76 26,-70 26,-64 26,-64 26,-38 26,-38 26,-32 32,-26 38,-26 38,-26 172.5,-26 172.5,-26 178.5,-26 184.5,-32 184.5,-38 184.5,-38 184.5,-64 184.5,-64 184.5,-70 178.5,-76 172.5,-76"/>
<text text-anchor="middle" x="105.25" y="-58.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Fixture Rows</text>
<text text-anchor="middle" x="105.25" y="-37.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(graphs, vertices, edge)</text>
</g>
<!-- transact -->
<g id="node6" class="node">
<title>transact</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" d="M1061.75,-221.12C1061.75,-221.12 893.75,-221.12 893.75,-221.12 887.75,-221.12 881.75,-215.12 881.75,-209.12 881.75,-209.12 881.75,-80.88 881.75,-80.88 881.75,-74.88 887.75,-68.88 893.75,-68.88 893.75,-68.88 1061.75,-68.88 1061.75,-68.88 1067.75,-68.88 1073.75,-74.88 1073.75,-80.88 1073.75,-80.88 1073.75,-209.12 1073.75,-209.12 1073.75,-215.12 1067.75,-221.12 1061.75,-221.12"/>
<text text-anchor="start" x="893.75" y="-200.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">add_paths_data (transact)</text>
<text text-anchor="start" x="893.75" y="-171.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• append Graphs rows</text>
<text text-anchor="start" x="893.75" y="-142.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• append G0, G1 rows</text>
<text text-anchor="start" x="893.75" y="-113.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• append G.V vertices</text>
<text text-anchor="start" x="893.75" y="-84.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• append G.E edge</text>
</g>
<!-- fixture_rows&#45;&gt;transact -->
<g id="edge5" class="edge">
<title>fixture_rows&#45;&gt;transact</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" stroke-dasharray="5,2" d="M184.91,-51C237.25,-51 307.55,-51 369.62,-51 369.62,-51 369.62,-51 739.5,-51 784.44,-51 831.02,-65.76 870.87,-83.58"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2" points="869.2,-86.66 879.74,-87.66 872.12,-80.3 869.2,-86.66"/>
</g>
<!-- flat_theory -->
<g id="node4" class="node">
<title>flat_theory</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M583.25,-221.12C583.25,-221.12 513.5,-221.12 513.5,-221.12 507.5,-221.12 501.5,-215.12 501.5,-209.12 501.5,-209.12 501.5,-138.88 501.5,-138.88 501.5,-132.88 507.5,-126.88 513.5,-126.88 513.5,-126.88 583.25,-126.88 583.25,-126.88 589.25,-126.88 595.25,-132.88 595.25,-138.88 595.25,-138.88 595.25,-209.12 595.25,-209.12 595.25,-215.12 589.25,-221.12 583.25,-221.12"/>
<text text-anchor="start" x="513.5" y="-200.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">FlatTheory</text>
<text text-anchor="start" x="513.5" y="-171.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• 10 tables</text>
<text text-anchor="start" x="513.5" y="-142.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• 12 laws</text>
</g>
<!-- load_theory&#45;&gt;flat_theory -->
<g id="edge2" class="edge">
<title>load_theory&#45;&gt;flat_theory</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" d="M434.58,-166.94C452.51,-168.06 471.95,-169.28 489.56,-170.38"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2" points="489.11,-173.86 499.31,-170.99 489.55,-166.87 489.11,-173.86"/>
</g>
<!-- build_store -->
<g id="node5" class="node">
<title>build_store</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" d="M802.75,-174C802.75,-174 674.25,-174 674.25,-174 668.25,-174 662.25,-168 662.25,-162 662.25,-162 662.25,-150 662.25,-150 662.25,-144 668.25,-138 674.25,-138 674.25,-138 802.75,-138 802.75,-138 808.75,-138 814.75,-144 814.75,-150 814.75,-150 814.75,-162 814.75,-162 814.75,-168 808.75,-174 802.75,-174"/>
<text text-anchor="middle" x="738.5" y="-153.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Store::try_from_theory</text>
</g>
<!-- flat_theory&#45;&gt;build_store -->
<g id="edge3" class="edge">
<title>flat_theory&#45;&gt;build_store</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" d="M595.4,-169.6C611.97,-168.01 631.27,-166.16 650.19,-164.35"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2" points="650.48,-167.84 660.1,-163.41 649.81,-160.87 650.48,-167.84"/>
</g>
<!-- demo_report -->
<g id="node13" class="node">
<title>demo_report</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5" d="M2390.75,-348.12C2390.75,-348.12 2221.25,-348.12 2221.25,-348.12 2215.25,-348.12 2209.25,-342.12 2209.25,-336.12 2209.25,-336.12 2209.25,-207.88 2209.25,-207.88 2209.25,-201.88 2215.25,-195.88 2221.25,-195.88 2221.25,-195.88 2390.75,-195.88 2390.75,-195.88 2396.75,-195.88 2402.75,-201.88 2402.75,-207.88 2402.75,-207.88 2402.75,-336.12 2402.75,-336.12 2402.75,-342.12 2396.75,-348.12 2390.75,-348.12"/>
<text text-anchor="start" x="2264.75" y="-327.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">DemoReport</text>
<text text-anchor="start" x="2221.25" y="-298.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• table_count, law_count</text>
<text text-anchor="start" x="2221.25" y="-269.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• graph, vertex, edge counts</text>
<text text-anchor="start" x="2221.25" y="-240.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• edge endpoints</text>
<text text-anchor="start" x="2221.25" y="-211.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• persisted_bytes</text>
</g>
<!-- flat_theory&#45;&gt;demo_report -->
<g id="edge15" class="edge">
<title>flat_theory&#45;&gt;demo_report</title>
<path fill="none" stroke="#607d8b" stroke-width="1.2" stroke-dasharray="5,2" d="M583.12,-221.62C616.93,-263.22 673.09,-317 737.5,-317 737.5,-317 737.5,-317 2064.38,-317 2108.37,-317 2156.13,-309.64 2197.42,-300.88"/>
<polygon fill="#607d8b" stroke="#607d8b" stroke-width="1.2" points="2198.08,-304.31 2207.11,-298.76 2196.59,-297.47 2198.08,-304.31"/>
<text text-anchor="middle" x="1456.88" y="-321.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">counts</text>
</g>
<!-- build_store&#45;&gt;transact -->
<g id="edge4" class="edge">
<title>build_store&#45;&gt;transact</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" d="M815.22,-152.49C832.54,-151.69 851.28,-150.82 869.64,-149.97"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2" points="869.68,-153.47 879.51,-149.51 869.36,-146.48 869.68,-153.47"/>
</g>
<!-- validate -->
<g id="node7" class="node">
<title>validate</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" d="M1321.12,-119C1321.12,-119 1184.38,-119 1184.38,-119 1178.38,-119 1172.38,-113 1172.38,-107 1172.38,-107 1172.38,-81 1172.38,-81 1172.38,-75 1178.38,-69 1184.38,-69 1184.38,-69 1321.12,-69 1321.12,-69 1327.12,-69 1333.12,-75 1333.12,-81 1333.12,-81 1333.12,-107 1333.12,-107 1333.12,-113 1327.12,-119 1321.12,-119"/>
<text text-anchor="middle" x="1252.75" y="-101.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Law Validation</text>
<text text-anchor="middle" x="1252.75" y="-80.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(append_row_validated)</text>
</g>
<!-- transact&#45;&gt;validate -->
<g id="edge6" class="edge">
<title>transact&#45;&gt;validate</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" d="M1074.14,-104.34C1084.98,-100.97 1096,-98.06 1106.75,-96 1123.99,-92.69 1142.59,-91.03 1160.54,-90.38"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2" points="1160.5,-93.88 1170.41,-90.13 1160.32,-86.88 1160.5,-93.88"/>
</g>
<!-- assert_edge -->
<g id="node8" class="node">
<title>assert_edge</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" d="M1324.5,-219C1324.5,-219 1181,-219 1181,-219 1175,-219 1169,-213 1169,-207 1169,-207 1169,-181 1169,-181 1169,-175 1175,-169 1181,-169 1181,-169 1324.5,-169 1324.5,-169 1330.5,-169 1336.5,-175 1336.5,-181 1336.5,-181 1336.5,-207 1336.5,-207 1336.5,-213 1330.5,-219 1324.5,-219"/>
<text text-anchor="middle" x="1252.75" y="-201.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">assert_edge_was_stored</text>
<text text-anchor="middle" x="1252.75" y="-180.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(row count, cells)</text>
</g>
<!-- transact&#45;&gt;assert_edge -->
<g id="edge8" class="edge">
<title>transact&#45;&gt;assert_edge</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" d="M1074.01,-166.62C1085.03,-168.89 1096.12,-171.07 1106.75,-173 1123.02,-175.95 1140.37,-178.76 1157.21,-181.31"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2" points="1156.57,-184.75 1166.97,-182.76 1157.6,-177.82 1156.57,-184.75"/>
</g>
<!-- validate&#45;&gt;transact -->
<g id="edge7" class="edge">
<title>validate&#45;&gt;transact</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" stroke-dasharray="5,2" d="M1172.15,-108.87C1145.1,-113.92 1114.42,-119.65 1085.6,-125.04"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2" points="1085.2,-121.55 1076.02,-126.83 1086.49,-128.43 1085.2,-121.55"/>
<text text-anchor="middle" x="1121.38" y="-125.98" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">row ids</text>
</g>
<!-- dump_before -->
<g id="node9" class="node">
<title>dump_before</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5" d="M1498.25,-210C1498.25,-210 1415.5,-210 1415.5,-210 1409.5,-210 1403.5,-204 1403.5,-198 1403.5,-198 1403.5,-172 1403.5,-172 1403.5,-166 1409.5,-160 1415.5,-160 1415.5,-160 1498.25,-160 1498.25,-160 1504.25,-160 1510.25,-166 1510.25,-172 1510.25,-172 1510.25,-198 1510.25,-198 1510.25,-204 1504.25,-210 1498.25,-210"/>
<text text-anchor="middle" x="1456.88" y="-192.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">store.dump()</text>
<text text-anchor="middle" x="1456.88" y="-171.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(before persist)</text>
</g>
<!-- assert_edge&#45;&gt;dump_before -->
<g id="edge9" class="edge">
<title>assert_edge&#45;&gt;dump_before</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2" d="M1336.84,-190.3C1355.06,-189.49 1374.09,-188.64 1391.45,-187.87"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2" points="1391.53,-191.37 1401.36,-187.43 1391.22,-184.38 1391.53,-191.37"/>
</g>
<!-- encode -->
<g id="node10" class="node">
<title>encode</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5" d="M1688.5,-145C1688.5,-145 1589.25,-145 1589.25,-145 1583.25,-145 1577.25,-139 1577.25,-133 1577.25,-133 1577.25,-107 1577.25,-107 1577.25,-101 1583.25,-95 1589.25,-95 1589.25,-95 1688.5,-95 1688.5,-95 1694.5,-95 1700.5,-101 1700.5,-107 1700.5,-107 1700.5,-133 1700.5,-133 1700.5,-139 1694.5,-145 1688.5,-145"/>
<text text-anchor="middle" x="1638.88" y="-127.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">pst::encode_store</text>
<text text-anchor="middle" x="1638.88" y="-106.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">&#45;&gt; bytes</text>
</g>
<!-- dump_before&#45;&gt;encode -->
<g id="edge10" class="edge">
<title>dump_before&#45;&gt;encode</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2" d="M1510.42,-166.02C1527.78,-159.76 1547.39,-152.67 1565.94,-145.98"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2" points="1567.09,-149.28 1575.31,-142.59 1564.71,-142.7 1567.09,-149.28"/>
</g>
<!-- compare -->
<g id="node12" class="node">
<title>compare</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5" d="M2121.25,-210C2121.25,-210 2005.5,-210 2005.5,-210 1999.5,-210 1993.5,-204 1993.5,-198 1993.5,-198 1993.5,-186 1993.5,-186 1993.5,-180 1999.5,-174 2005.5,-174 2005.5,-174 2121.25,-174 2121.25,-174 2127.25,-174 2133.25,-180 2133.25,-186 2133.25,-186 2133.25,-198 2133.25,-198 2133.25,-204 2127.25,-210 2121.25,-210"/>
<text text-anchor="middle" x="2063.38" y="-189.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">dump equality check</text>
</g>
<!-- dump_before&#45;&gt;compare -->
<g id="edge13" class="edge">
<title>dump_before&#45;&gt;compare</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2" stroke-dasharray="5,2" d="M1510.73,-185.61C1616.73,-186.84 1855.9,-189.61 1981.64,-191.07"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2" points="1981.22,-194.56 1991.26,-191.18 1981.3,-187.56 1981.22,-194.56"/>
<text text-anchor="middle" x="1751.88" y="-193.55" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">expected</text>
</g>
<!-- decode -->
<g id="node11" class="node">
<title>decode</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5" d="M1914.5,-145C1914.5,-145 1815.25,-145 1815.25,-145 1809.25,-145 1803.25,-139 1803.25,-133 1803.25,-133 1803.25,-107 1803.25,-107 1803.25,-101 1809.25,-95 1815.25,-95 1815.25,-95 1914.5,-95 1914.5,-95 1920.5,-95 1926.5,-101 1926.5,-107 1926.5,-107 1926.5,-133 1926.5,-133 1926.5,-139 1920.5,-145 1914.5,-145"/>
<text text-anchor="middle" x="1864.88" y="-127.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">pst::decode_store</text>
<text text-anchor="middle" x="1864.88" y="-106.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">&#45;&gt; restored Store</text>
</g>
<!-- encode&#45;&gt;decode -->
<g id="edge11" class="edge">
<title>encode&#45;&gt;decode</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2" d="M1700.79,-120C1728.68,-120 1761.96,-120 1791.16,-120"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2" points="1791.16,-123.5 1801.16,-120 1791.16,-116.5 1791.16,-123.5"/>
<text text-anchor="middle" x="1751.88" y="-124.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">bytes</text>
</g>
<!-- decode&#45;&gt;compare -->
<g id="edge12" class="edge">
<title>decode&#45;&gt;compare</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2" d="M1926.97,-142.39C1950.61,-151.05 1977.6,-160.94 2001.24,-169.6"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2" points="1999.89,-172.83 2010.49,-172.99 2002.3,-166.26 1999.89,-172.83"/>
</g>
<!-- compare&#45;&gt;demo_report -->
<g id="edge14" class="edge">
<title>compare&#45;&gt;demo_report</title>
<path fill="none" stroke="#607d8b" stroke-width="1.2" d="M2119.88,-210.45C2143.18,-218.2 2171.1,-227.48 2198.11,-236.46"/>
<polygon fill="#607d8b" stroke="#607d8b" stroke-width="1.2" points="2196.89,-239.75 2207.48,-239.58 2199.1,-233.1 2196.89,-239.75"/>
</g>
<!-- stdout -->
<g id="node14" class="node">
<title>stdout</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5" d="M2554.75,-297C2554.75,-297 2481.75,-297 2481.75,-297 2475.75,-297 2469.75,-291 2469.75,-285 2469.75,-285 2469.75,-259 2469.75,-259 2469.75,-253 2475.75,-247 2481.75,-247 2481.75,-247 2554.75,-247 2554.75,-247 2560.75,-247 2566.75,-253 2566.75,-259 2566.75,-259 2566.75,-285 2566.75,-285 2566.75,-291 2560.75,-297 2554.75,-297"/>
<text text-anchor="middle" x="2518.25" y="-279.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">stdout</text>
<text text-anchor="middle" x="2518.25" y="-258.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">(println! lines)</text>
</g>
<!-- demo_report&#45;&gt;stdout -->
<g id="edge16" class="edge">
<title>demo_report&#45;&gt;stdout</title>
<path fill="none" stroke="#607d8b" stroke-width="1.2" d="M2403.2,-272C2421.83,-272 2440.83,-272 2457.85,-272"/>
<polygon fill="#607d8b" stroke="#607d8b" stroke-width="1.2" points="2457.46,-275.5 2467.46,-272 2457.46,-268.5 2457.46,-275.5"/>
</g>
</g>
<svg width="3020pt" height="407pt"
viewBox="0.00 0.00 3020.25 407.00" xmlns="http://www.w3.org/2000/svg">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 403)">
<title>GeomergeDemoWorkflow</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-403 3016.25,-403 3016.25,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_inputs</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2"
points="8,-11 8,-226 202.5,-226 202.5,-11 8,-11"/>
<text text-anchor="middle" x="105.25" y="-208.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#555555">Inputs
</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_demo</title>
<path fill="#fafafa" stroke="#666666"
d="M284.75,-8C284.75,-8 2574.75,-8 2574.75,-8 2580.75,-8 2586.75,-14 2586.75,-20 2586.75,-20 2586.75,-304 2586.75,-304 2586.75,-310 2580.75,-316 2574.75,-316 2574.75,-316 284.75,-316 284.75,-316 278.75,-316 272.75,-310 272.75,-304 272.75,-304 272.75,-20 272.75,-20 272.75,-14 278.75,-8 284.75,-8"/>
<text text-anchor="middle" x="1429.75" y="-298.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#333333">geomerge&#45;demo (run_demo)
</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_loading</title>
<polygon fill="#fafafa" stroke="#9c27b0" stroke-dasharray="5,2"
points="292.75,-116 292.75,-267 609.25,-267 609.25,-116 292.75,-116"/>
<text text-anchor="middle" x="451" y="-249.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#7b1fa2">Theory Loading
</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_store</title>
<polygon fill="#fafafa" stroke="#4caf50" stroke-dasharray="5,2"
points="648.25,-28 648.25,-267 1744,-267 1744,-28 648.25,-28"/>
<text text-anchor="middle" x="1196.12" y="-249.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#388e3c">Storage and Transaction
</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_persist</title>
<polygon fill="#fafafa" stroke="#ff9800" stroke-dasharray="5,2"
points="1809,-77 1809,-267 2566.75,-267 2566.75,-77 1809,-77"/>
<text text-anchor="middle" x="2187.88" y="-249.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#f57c00">Persistence Round Trip
</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_report</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2"
points="2610.75,-174 2610.75,-391 3004.25,-391 3004.25,-174 2610.75,-174"/>
<text text-anchor="middle" x="2807.5" y="-373.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#555555">Report
</text>
</g>
<!-- paths_schema -->
<g id="node1" class="node">
<title>paths_schema</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M159,-179C159,-179 51.5,-179 51.5,-179 45.5,-179 39.5,-173 39.5,-167 39.5,-167 39.5,-141 39.5,-141 39.5,-135 45.5,-129 51.5,-129 51.5,-129 159,-129 159,-129 165,-129 171,-135 171,-141 171,-141 171,-167 171,-167 171,-173 165,-179 159,-179"/>
<text text-anchor="middle" x="105.25" y="-161.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
paths.json
</text>
<text text-anchor="middle" x="105.25" y="-140.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
(compiled schema)
</text>
</g>
<!-- load_theory -->
<g id="node3" class="node">
<title>load_theory</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M422.5,-191C422.5,-191 318.75,-191 318.75,-191 312.75,-191 306.75,-185 306.75,-179 306.75,-179 306.75,-153 306.75,-153 306.75,-147 312.75,-141 318.75,-141 318.75,-141 422.5,-141 422.5,-141 428.5,-141 434.5,-147 434.5,-153 434.5,-153 434.5,-179 434.5,-179 434.5,-185 428.5,-191 422.5,-191"/>
<text text-anchor="middle" x="370.62" y="-173.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
load_paths_theory
</text>
<text text-anchor="middle" x="370.62" y="-152.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
(serde_json)
</text>
</g>
<!-- paths_schema&#45;&gt;load_theory -->
<g id="edge1" class="edge">
<title>paths_schema&#45;&gt;load_theory</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2"
d="M171.35,-156.97C208.77,-158.67 255.91,-160.82 294.83,-162.59"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="294.67,-166.09 304.82,-163.05 294.99,-159.1 294.67,-166.09"/>
<text text-anchor="middle" x="241.12" y="-166.02" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">include_str!
</text>
</g>
<!-- fixture_rows -->
<g id="node2" class="node">
<title>fixture_rows</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M172.5,-79C172.5,-79 38,-79 38,-79 32,-79 26,-73 26,-67 26,-67 26,-41 26,-41 26,-35 32,-29 38,-29 38,-29 172.5,-29 172.5,-29 178.5,-29 184.5,-35 184.5,-41 184.5,-41 184.5,-67 184.5,-67 184.5,-73 178.5,-79 172.5,-79"/>
<text text-anchor="middle" x="105.25" y="-61.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Fixture Rows
</text>
<text text-anchor="middle" x="105.25" y="-40.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
(graphs, vertices, edge)
</text>
</g>
<!-- transact -->
<g id="node6" class="node">
<title>transact</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1147.25,-223.62C1147.25,-223.62 950.75,-223.62 950.75,-223.62 944.75,-223.62 938.75,-217.62 938.75,-211.62 938.75,-211.62 938.75,-54.38 938.75,-54.38 938.75,-48.38 944.75,-42.38 950.75,-42.38 950.75,-42.38 1147.25,-42.38 1147.25,-42.38 1153.25,-42.38 1159.25,-48.38 1159.25,-54.38 1159.25,-54.38 1159.25,-211.62 1159.25,-211.62 1159.25,-217.62 1153.25,-223.62 1147.25,-223.62"/>
<text text-anchor="start" x="954.88" y="-203.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">add_paths_data (tx.insert ×7)
</text>
<text text-anchor="start" x="950.75" y="-174.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• insert Graphs rows
</text>
<text text-anchor="start" x="950.75" y="-145.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• insert G0, G1 rows
</text>
<text text-anchor="start" x="950.75" y="-116.08" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• insert G.V vertices
</text>
<text text-anchor="start" x="950.75" y="-87.08" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
insert G.E edge
</text>
<text text-anchor="start" x="950.75" y="-58.08" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
pending RowIds reused as FKs
</text>
</g>
<!-- fixture_rows&#45;&gt;transact -->
<g id="edge5" class="edge">
<title>fixture_rows&#45;&gt;transact</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" stroke-dasharray="5,2"
d="M184.91,-54C237.25,-54 307.55,-54 369.62,-54 369.62,-54 369.62,-54 768,-54 821.42,-54 878.49,-67.48 927.08,-83.31"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="925.95,-86.62 936.55,-86.47 928.17,-79.98 925.95,-86.62"/>
</g>
<!-- flat_theory -->
<g id="node4" class="node">
<title>flat_theory</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M583.25,-224.12C583.25,-224.12 513.5,-224.12 513.5,-224.12 507.5,-224.12 501.5,-218.12 501.5,-212.12 501.5,-212.12 501.5,-141.88 501.5,-141.88 501.5,-135.88 507.5,-129.88 513.5,-129.88 513.5,-129.88 583.25,-129.88 583.25,-129.88 589.25,-129.88 595.25,-135.88 595.25,-141.88 595.25,-141.88 595.25,-212.12 595.25,-212.12 595.25,-218.12 589.25,-224.12 583.25,-224.12"/>
<text text-anchor="start" x="513.5" y="-203.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">FlatTheory
</text>
<text text-anchor="start" x="513.5" y="-174.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
10 tables
</text>
<text text-anchor="start" x="513.5" y="-145.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
12 laws
</text>
</g>
<!-- load_theory&#45;&gt;flat_theory -->
<g id="edge2" class="edge">
<title>load_theory&#45;&gt;flat_theory</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2"
d="M434.58,-169.94C452.51,-171.06 471.95,-172.28 489.56,-173.38"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="489.11,-176.86 499.31,-173.99 489.55,-169.87 489.11,-176.86"/>
</g>
<!-- build_store -->
<g id="node5" class="node">
<title>build_store</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M859.75,-186C859.75,-186 674.25,-186 674.25,-186 668.25,-186 662.25,-180 662.25,-174 662.25,-174 662.25,-148 662.25,-148 662.25,-142 668.25,-136 674.25,-136 674.25,-136 859.75,-136 859.75,-136 865.75,-136 871.75,-142 871.75,-148 871.75,-148 871.75,-174 871.75,-174 871.75,-180 865.75,-186 859.75,-186"/>
<text text-anchor="middle" x="767" y="-168.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
GeomergeStorage::from_theory
</text>
<text text-anchor="middle" x="767" y="-147.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
(Store::try_from_theory)
</text>
</g>
<!-- flat_theory&#45;&gt;build_store -->
<g id="edge3" class="edge">
<title>flat_theory&#45;&gt;build_store</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2"
d="M595.58,-173.59C611.81,-172.39 630.85,-170.98 650.22,-169.55"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="650.44,-173.05 660.15,-168.82 649.92,-166.06 650.44,-173.05"/>
</g>
<!-- demo_report -->
<g id="node13" class="node">
<title>demo_report</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5"
d="M2810.25,-344.12C2810.25,-344.12 2640.75,-344.12 2640.75,-344.12 2634.75,-344.12 2628.75,-338.12 2628.75,-332.12 2628.75,-332.12 2628.75,-203.88 2628.75,-203.88 2628.75,-197.88 2634.75,-191.88 2640.75,-191.88 2640.75,-191.88 2810.25,-191.88 2810.25,-191.88 2816.25,-191.88 2822.25,-197.88 2822.25,-203.88 2822.25,-203.88 2822.25,-332.12 2822.25,-332.12 2822.25,-338.12 2816.25,-344.12 2810.25,-344.12"/>
<text text-anchor="start" x="2684.25" y="-323.82" font-family="Helvetica,Arial,sans-serif"
font-weight="bold" font-size="14.00">DemoReport
</text>
<text text-anchor="start" x="2640.75" y="-294.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• table_count, law_count
</text>
<text text-anchor="start" x="2640.75" y="-265.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• graph, vertex, edge counts
</text>
<text text-anchor="start" x="2640.75" y="-236.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• edge endpoints
</text>
<text text-anchor="start" x="2640.75" y="-207.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• persisted_bytes
</text>
</g>
<!-- flat_theory&#45;&gt;demo_report -->
<g id="edge14" class="edge">
<title>flat_theory&#45;&gt;demo_report</title>
<path fill="none" stroke="#607d8b" stroke-width="1.2" stroke-dasharray="5,2"
d="M586.43,-224.45C603.41,-243.31 624.95,-263.65 648.25,-277 695.11,-303.85 711.99,-307 766,-307 766,-307 766,-307 2483.88,-307 2527.78,-307 2575.62,-300.59 2616.99,-292.98"/>
<polygon fill="#607d8b" stroke="#607d8b" stroke-width="1.2"
points="2617.52,-296.44 2626.7,-291.14 2616.22,-289.56 2617.52,-296.44"/>
<text text-anchor="middle" x="1776.5" y="-311.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">counts
</text>
</g>
<!-- build_store&#45;&gt;transact -->
<g id="edge4" class="edge">
<title>build_store&#45;&gt;transact</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M871.86,-150.61C889.82,-148.82 908.63,-146.94 927,-145.1"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="927.27,-148.59 936.87,-144.11 926.57,-141.63 927.27,-148.59"/>
</g>
<!-- commit -->
<g id="node7" class="node">
<title>commit</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1483.5,-180.12C1483.5,-180.12 1238.25,-180.12 1238.25,-180.12 1232.25,-180.12 1226.25,-174.12 1226.25,-168.12 1226.25,-168.12 1226.25,-97.88 1226.25,-97.88 1226.25,-91.88 1232.25,-85.88 1238.25,-85.88 1238.25,-85.88 1483.5,-85.88 1483.5,-85.88 1489.5,-85.88 1495.5,-91.88 1495.5,-97.88 1495.5,-97.88 1495.5,-168.12 1495.5,-168.12 1495.5,-174.12 1489.5,-180.12 1483.5,-180.12"/>
<text text-anchor="start" x="1323.75" y="-159.82" font-family="Helvetica,Arial,sans-serif"
font-weight="bold" font-size="14.00">tx.commit()
</text>
<text text-anchor="start" x="1238.25" y="-130.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• law validation
</text>
<text text-anchor="start" x="1238.25" y="-101.58" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• CommittedTx resolves pending RowIds
</text>
</g>
<!-- transact&#45;&gt;commit -->
<g id="edge6" class="edge">
<title>transact&#45;&gt;commit</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M1159.69,-133C1177.34,-133 1195.85,-133 1214.18,-133"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1214.06,-136.5 1224.06,-133 1214.06,-129.5 1214.06,-136.5"/>
</g>
<!-- assert_edge -->
<g id="node8" class="node">
<title>assert_edge</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1718,-158C1718,-158 1574.5,-158 1574.5,-158 1568.5,-158 1562.5,-152 1562.5,-146 1562.5,-146 1562.5,-120 1562.5,-120 1562.5,-114 1568.5,-108 1574.5,-108 1574.5,-108 1718,-108 1718,-108 1724,-108 1730,-114 1730,-120 1730,-120 1730,-146 1730,-146 1730,-152 1724,-158 1718,-158"/>
<text text-anchor="middle" x="1646.25" y="-140.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">assert_edge_was_stored
</text>
<text text-anchor="middle" x="1646.25" y="-119.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">(storage.scan(G.E))
</text>
</g>
<!-- commit&#45;&gt;assert_edge -->
<g id="edge7" class="edge">
<title>commit&#45;&gt;assert_edge</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M1495.68,-133C1514.16,-133 1532.83,-133 1550.41,-133"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1550.27,-136.5 1560.27,-133 1550.27,-129.5 1550.27,-136.5"/>
</g>
<!-- dump_before -->
<g id="node9" class="node">
<title>dump_before</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M1917.75,-159C1917.75,-159 1835,-159 1835,-159 1829,-159 1823,-153 1823,-147 1823,-147 1823,-121 1823,-121 1823,-115 1829,-109 1835,-109 1835,-109 1917.75,-109 1917.75,-109 1923.75,-109 1929.75,-115 1929.75,-121 1929.75,-121 1929.75,-147 1929.75,-147 1929.75,-153 1923.75,-159 1917.75,-159"/>
<text text-anchor="middle" x="1876.38" y="-141.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">store.dump()
</text>
<text text-anchor="middle" x="1876.38" y="-120.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">(before persist)
</text>
</g>
<!-- assert_edge&#45;&gt;dump_before -->
<g id="edge8" class="edge">
<title>assert_edge&#45;&gt;dump_before</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M1730.25,-133.36C1756.87,-133.48 1785.93,-133.61 1811,-133.72"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="1810.76,-137.22 1820.77,-133.76 1810.79,-130.22 1810.76,-137.22"/>
</g>
<!-- encode -->
<g id="node10" class="node">
<title>encode</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M2108,-159C2108,-159 2008.75,-159 2008.75,-159 2002.75,-159 1996.75,-153 1996.75,-147 1996.75,-147 1996.75,-121 1996.75,-121 1996.75,-115 2002.75,-109 2008.75,-109 2008.75,-109 2108,-109 2108,-109 2114,-109 2120,-115 2120,-121 2120,-121 2120,-147 2120,-147 2120,-153 2114,-159 2108,-159"/>
<text text-anchor="middle" x="2058.38" y="-141.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">pst::encode_store
</text>
<text text-anchor="middle" x="2058.38" y="-120.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">&#45;&gt; bytes
</text>
</g>
<!-- dump_before&#45;&gt;encode -->
<g id="edge9" class="edge">
<title>dump_before&#45;&gt;encode</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M1929.92,-134C1947.11,-134 1966.52,-134 1984.92,-134"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="1984.7,-137.5 1994.7,-134 1984.7,-130.5 1984.7,-137.5"/>
</g>
<!-- compare -->
<g id="node12" class="node">
<title>compare</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M2540.75,-224C2540.75,-224 2425,-224 2425,-224 2419,-224 2413,-218 2413,-212 2413,-212 2413,-200 2413,-200 2413,-194 2419,-188 2425,-188 2425,-188 2540.75,-188 2540.75,-188 2546.75,-188 2552.75,-194 2552.75,-200 2552.75,-200 2552.75,-212 2552.75,-212 2552.75,-218 2546.75,-224 2540.75,-224"/>
<text text-anchor="middle" x="2482.88" y="-203.2" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">dump equality check
</text>
</g>
<!-- dump_before&#45;&gt;compare -->
<g id="edge12" class="edge">
<title>dump_before&#45;&gt;compare</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2" stroke-dasharray="5,2"
d="M1927.2,-159.48C1948.13,-168.95 1973.07,-178.68 1996.75,-184 2136.12,-215.31 2303.52,-214.81 2400.78,-210.9"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="2400.93,-214.39 2410.77,-210.47 2400.63,-207.4 2400.93,-214.39"/>
<text text-anchor="middle" x="2171.38" y="-214.27" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">expected
</text>
</g>
<!-- decode -->
<g id="node11" class="node">
<title>decode</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M2334,-159C2334,-159 2234.75,-159 2234.75,-159 2228.75,-159 2222.75,-153 2222.75,-147 2222.75,-147 2222.75,-121 2222.75,-121 2222.75,-115 2228.75,-109 2234.75,-109 2234.75,-109 2334,-109 2334,-109 2340,-109 2346,-115 2346,-121 2346,-121 2346,-147 2346,-147 2346,-153 2340,-159 2334,-159"/>
<text text-anchor="middle" x="2284.38" y="-141.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">pst::decode_store
</text>
<text text-anchor="middle" x="2284.38" y="-120.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">&#45;&gt; restored Store
</text>
</g>
<!-- encode&#45;&gt;decode -->
<g id="edge10" class="edge">
<title>encode&#45;&gt;decode</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M2120.29,-134C2148.18,-134 2181.46,-134 2210.66,-134"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="2210.66,-137.5 2220.66,-134 2210.66,-130.5 2210.66,-137.5"/>
<text text-anchor="middle" x="2171.38" y="-138.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">bytes
</text>
</g>
<!-- decode&#45;&gt;compare -->
<g id="edge11" class="edge">
<title>decode&#45;&gt;compare</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M2346.47,-156.39C2370.11,-165.05 2397.1,-174.94 2420.74,-183.6"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="2419.39,-186.83 2429.99,-186.99 2421.8,-180.26 2419.39,-186.83"/>
</g>
<!-- compare&#45;&gt;demo_report -->
<g id="edge13" class="edge">
<title>compare&#45;&gt;demo_report</title>
<path fill="none" stroke="#607d8b" stroke-width="1.2"
d="M2553.15,-223.85C2573.13,-229 2595.42,-234.74 2617.13,-240.34"/>
<polygon fill="#607d8b" stroke="#607d8b" stroke-width="1.2"
points="2616.11,-243.69 2626.67,-242.79 2617.85,-236.91 2616.11,-243.69"/>
</g>
<!-- stdout -->
<g id="node14" class="node">
<title>stdout</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5"
d="M2974.25,-293C2974.25,-293 2901.25,-293 2901.25,-293 2895.25,-293 2889.25,-287 2889.25,-281 2889.25,-281 2889.25,-255 2889.25,-255 2889.25,-249 2895.25,-243 2901.25,-243 2901.25,-243 2974.25,-243 2974.25,-243 2980.25,-243 2986.25,-249 2986.25,-255 2986.25,-255 2986.25,-281 2986.25,-281 2986.25,-287 2980.25,-293 2974.25,-293"/>
<text text-anchor="middle" x="2937.75" y="-275.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">stdout
</text>
<text text-anchor="middle" x="2937.75" y="-254.7" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">(println! lines)
</text>
</g>
<!-- demo_report&#45;&gt;stdout -->
<g id="edge15" class="edge">
<title>demo_report&#45;&gt;stdout</title>
<path fill="none" stroke="#607d8b" stroke-width="1.2"
d="M2822.7,-268C2841.33,-268 2860.33,-268 2877.35,-268"/>
<polygon fill="#607d8b" stroke="#607d8b" stroke-width="1.2"
points="2876.96,-271.5 2886.96,-268 2876.96,-264.5 2876.96,-271.5"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -1,25 +1,26 @@
//! Geomerge storage demo, routed through `storage`.
//!
//! With the v3 trait, [`Storage::transaction`] returns a transaction handle
//! that supports batched inserts with in-flight foreign-key references via
//! pending [`RowId`](storage::id::RowId)s. The demo's chain of related
//! inserts now goes entirely through the trait. Theory loading, persistence
//! (`encode_store`/`decode_store`), and `Store::dump` remain direct
//! geomerge calls, since the trait does not model them.
use std::error::Error;
use geomerge::{
ir::{FlatTheory, Path},
persist::pst,
store::{Store, StoreIntError},
table::{CellValue, RowId, ValidationError},
};
use geomerge::commit::pst;
use geomerge::ir::FlatTheory;
use storage::adapters::geomerge::GeomergeStorage;
use storage::id::RowId;
use storage::value::Value;
use storage::{Storage, StorageError};
const PATHS_SCHEMA_JSON: &str =
include_str!("../../../external/geomerge/crates/geomerge/tests/data/paths.json");
type DemoResult<T> = Result<T, Box<dyn Error>>;
#[derive(Debug, Clone, Copy)]
struct DemoRows {
graph: RowId,
source_vertex: RowId,
target_vertex: RowId,
edge: RowId,
}
#[derive(Debug)]
struct DemoReport {
table_count: usize,
@ -42,7 +43,7 @@ fn main() -> DemoResult<()> {
println!("vertices stored: {}", report.vertex_count);
println!("edges stored: {}", report.edge_count);
println!(
"edge endpoints: #{} -> #{}",
"edge endpoints: {} -> {}",
report.edge_source, report.edge_target
);
println!("persisted store bytes: {}", report.persisted_bytes);
@ -55,26 +56,28 @@ fn run_demo() -> DemoResult<DemoReport> {
let table_count = theory.tables.len();
let law_count = theory.laws.len();
let mut store = Store::try_from_theory(theory)?;
let rows = add_paths_data(&mut store)?;
assert_edge_was_stored(&store, rows)?;
let mut storage = GeomergeStorage::from_theory(theory)?;
add_paths_data(&mut storage)?;
let (edge_source, edge_target) = assert_edge_was_stored(&storage)?;
let before_persist = store.dump();
let encoded = pst::encode_store(&store)?;
let restored = pst::decode_store(&encoded)?;
let before_persist = storage.store().dump();
let encoded = pst::encode_store(storage.store())?;
let restored_store = pst::decode_store(&encoded)?;
if before_persist != restored.dump() {
if before_persist != restored_store.dump() {
return Err("persisted store did not round-trip".into());
}
let restored = GeomergeStorage::from_store(restored_store);
Ok(DemoReport {
table_count,
law_count,
graph_count: row_count(&restored, "Graphs")?,
vertex_count: row_count(&restored, "G.V")?,
edge_count: row_count(&restored, "G.E")?,
edge_source: rows.source_vertex,
edge_target: rows.target_vertex,
edge_source,
edge_target,
persisted_bytes: encoded.len(),
})
}
@ -83,90 +86,50 @@ fn load_paths_theory() -> DemoResult<FlatTheory> {
Ok(serde_json::from_str(PATHS_SCHEMA_JSON)?)
}
fn add_paths_data(store: &mut Store) -> Result<DemoRows, Box<StoreIntError>> {
store.transact(|store| {
let g0_graph = append_row(store, "Graphs", vec![])?;
let designated_graph = append_row(store, "Graphs", vec![])?;
fn add_paths_data(storage: &mut GeomergeStorage) -> Result<(), StorageError> {
let mut tx = storage.transaction()?;
append_row(store, "G0", vec![CellValue::Id(designated_graph)])?;
append_row(store, "G1", vec![CellValue::Id(designated_graph)])?;
let source_vertex = append_row(store, "G.V", vec![CellValue::Id(g0_graph)])?;
let target_vertex = append_row(store, "G.V", vec![CellValue::Id(g0_graph)])?;
let edge = append_row(
store,
"G.E",
vec![
CellValue::Id(g0_graph),
CellValue::Id(source_vertex),
CellValue::Id(target_vertex),
],
)?;
Ok(DemoRows {
graph: g0_graph,
source_vertex,
target_vertex,
edge,
})
})
}
fn append_row(
store: &mut Store,
table: &str,
values: Vec<CellValue>,
) -> Result<RowId, Box<StoreIntError>> {
let path = Path::from(table);
let table = store
.table_at_mut(&path)
.ok_or_else(|| ValidationError::UnknownTable { path: path.clone() })?;
Ok(table.append_row_validated(values)?)
}
fn assert_edge_was_stored(store: &Store, rows: DemoRows) -> DemoResult<()> {
let edge_table = store
.table_at(&Path::from("G.E"))
.ok_or("missing G.E table after insert")?;
if edge_table.row_count() != 1 {
return Err(format!("expected one edge row, got {}", edge_table.row_count()).into());
}
if edge_table.row_id_at(0) != Some(rows.edge) {
return Err("stored edge row id did not match transaction output".into());
}
let expected = [
CellValue::Id(rows.graph),
CellValue::Id(rows.source_vertex),
CellValue::Id(rows.target_vertex),
];
for (column, expected_value) in expected.iter().enumerate() {
if edge_table.cell_at(0, column) != Some(expected_value) {
return Err(format!("unexpected G.E column {column}").into());
}
}
let g0_graph = tx.insert("Graphs", vec![])?;
let designated = tx.insert("Graphs", vec![])?;
tx.insert("G0", vec![Value::Id(designated.clone())])?;
tx.insert("G1", vec![Value::Id(designated)])?;
let source_vertex = tx.insert("G.V", vec![Value::Id(g0_graph.clone())])?;
let target_vertex = tx.insert("G.V", vec![Value::Id(g0_graph.clone())])?;
tx.insert(
"G.E",
vec![
Value::Id(g0_graph),
Value::Id(source_vertex),
Value::Id(target_vertex),
],
)?;
tx.commit()?;
Ok(())
}
fn row_count(store: &Store, table: &str) -> DemoResult<usize> {
let path = Path::from(table);
Ok(store
.table_at(&path)
.ok_or_else(|| format!("missing table {table}"))?
.row_count())
fn assert_edge_was_stored(storage: &GeomergeStorage) -> DemoResult<(RowId, RowId)> {
let edges = storage.scan("G.E")?;
if edges.len() != 1 {
return Err(format!("expected one edge row, got {}", edges.len()).into());
}
let (_edge_id, cells) = &edges[0];
let (source, target) = match cells.as_slice() {
[_graph, Value::Id(source), Value::Id(target)] => (source.clone(), target.clone()),
other => return Err(format!("unexpected G.E cells: {other:?}").into()),
};
Ok((source, target))
}
fn row_count(storage: &GeomergeStorage, table: &str) -> Result<usize, StorageError> {
Ok(storage.scan(table)?.len())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::{add_paths_data, load_paths_theory, row_count, run_demo};
use geomerge::{
ir::Path,
store::{Store, StoreIntError},
table::CellValue,
use super::{
GeomergeStorage, Storage, Value, add_paths_data, load_paths_theory, row_count, run_demo,
};
#[test]
@ -184,23 +147,48 @@ mod tests {
#[test]
fn invalid_edge_is_rejected_without_mutating_store() {
let theory = load_paths_theory().expect("fixture");
let mut store = Store::try_from_theory(theory).expect("store");
add_paths_data(&mut store).expect("initial valid data");
let mut storage = GeomergeStorage::from_theory(theory).expect("storage");
add_paths_data(&mut storage).expect("initial valid data");
let before_edges = row_count(&store, "G.E").expect("edge count");
let error = store
.transact(|store| {
let edge_table = store.table_at_mut(&Path::from("G.E")).expect("G.E table");
edge_table.append_row_validated(vec![
CellValue::Id(0),
CellValue::Id(0),
CellValue::Id(u64::MAX),
])?;
Ok(())
})
.expect_err("missing target vertex should violate a law");
let before_edges = row_count(&storage, "G.E").expect("edge count");
assert!(matches!(*error, StoreIntError::Law(_)));
assert_eq!(row_count(&store, "G.E").expect("edge count"), before_edges);
// Try to insert an edge with an existing source RowId but a bogus
// target (we reuse the source's bytes mutated so the RowId is valid
// shape but doesn't reference any actual G.V row). Geomerge should
// reject the commit via a foreign-key law violation, leaving the
// store unchanged.
let edges = storage.scan("G.E").expect("scan G.E");
let (_, edge_cells) = &edges[0];
let (graph_id, source_id) = match edge_cells.as_slice() {
[Value::Id(g), Value::Id(s), Value::Id(_)] => (g.clone(), s.clone()),
other => panic!("unexpected G.E cells: {other:?}"),
};
let bogus = {
let mut bytes = source_id.as_bytes().to_vec();
for b in &mut bytes[..32] {
*b ^= 0xFF;
}
storage::id::RowId::new(bytes)
};
let result = {
let mut tx = storage.transaction().expect("begin tx");
tx.insert(
"G.E",
vec![Value::Id(graph_id), Value::Id(source_id), Value::Id(bogus)],
)
.expect("add succeeds at txn level");
tx.commit()
};
assert!(
matches!(result, Err(storage::StorageError::Validation(_))),
"commit should fail with Validation, got {result:?}"
);
assert_eq!(
row_count(&storage, "G.E").expect("edge count after failed insert"),
before_edges
);
}
}

View File

@ -9,4 +9,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
query-storage = { path = "../query-storage" }
storage = { path = "../storage" }

View File

@ -10,11 +10,12 @@ The operators are: **atom scan**, **semijoin**, and **natural join**.
| `scan_atom(&Table, &AtomPattern) -> Relation` | function | Scans the table under the pattern and returns a binding relation with one column per distinct variable in first-occurrence order. Literal positions and repeated variables filter rows during the scan. |
| `semijoin(&Relation, &Relation) -> Relation` | function | Returns the rows of `left` whose values on the columns shared with `right` also appear in `right`. The output column list is the same as `left.columns`. |
| `natural_join(&Relation, &Relation) -> Relation` | function | Returns every pair of `left` and `right` rows that agree on shared columns. Each output row holds the columns of `left` followed by the non-shared columns of `right`. |
| `Table` | struct | Holds positional input rows of fixed arity and carries no column names. Construct it with `Table::new(arity)` or `Table::from_rows(arity, rows)`. |
| `AtomPattern` | struct | Specifies, for each table column, either a variable to bind or a literal value to match. The pattern is a `Vec<Term>` whose length must equal the table's arity. |
| `Term` | enum | Represents one position of an `AtomPattern`. A term is either `Var(String)` to bind the cell to a named variable, or `Lit(Value)` to require the cell to equal a given value. |
| `Relation` | struct | Holds rows over named columns and is the type produced by every operator. Construct it with `Relation::new(columns)` or `Relation::from_rows(columns, rows)`. Column names within a single relation must be unique. |
| `Value` | enum | Represents a single cell value stored in a `Table` or `Relation`. A value is either `Int(i64)` or `Str(String)`. |
The foundational types `Table` (positional input rows of fixed arity) and `Value` (`Int(i64)`, `Str(String)`, or `Id(RowId)`) live in the [
`storage`](../storage) crate; query-ops imports them.
Data types and their relationships:
@ -43,8 +44,8 @@ The code below implements the rule (also available [here](tests/hand_plan.rs)):
```rust
use query_ops::atom::{AtomPattern, Term, scan_atom};
use query_ops::join::{natural_join, semijoin};
use query_ops::table::Table;
use query_ops::value::Value;
use storage::table::Table;
use storage::value::Value;
fn s(x: &str) -> Value {
Value::Str(x.to_string())

View File

@ -50,6 +50,7 @@ value_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpaddin
<tr><td align="center"><b>Value</b> (enum)</td></tr>
<tr><td align="left" balign="left">Int(i64)</td></tr>
<tr><td align="left" balign="left">Str(String)</td></tr>
<tr><td align="left" balign="left">Id(RowId)</td></tr>
</table>>, fillcolor = "#FFF3E0", color = "#FF9800"]
// composition edges: arrow X -> Y reads "X contains Y"

View File

@ -1,85 +1,147 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.2.1 (0)
-->
<!-- Title: QueryOpsTypes Pages: 1 -->
<svg width="584pt" height="391pt"
viewBox="0.00 0.00 583.50 391.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 387)">
<title>QueryOpsTypes</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-387 579.5,-387 579.5,4 -4,4"/>
<!-- table_node -->
<g id="node1" class="node">
<title>table_node</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5" d="M159.75,-253.5C159.75,-253.5 12,-253.5 12,-253.5 6,-253.5 0,-247.5 0,-241.5 0,-241.5 0,-170.5 0,-170.5 0,-164.5 6,-158.5 12,-158.5 12,-158.5 159.75,-158.5 159.75,-158.5 165.75,-158.5 171.75,-164.5 171.75,-170.5 171.75,-170.5 171.75,-241.5 171.75,-241.5 171.75,-247.5 165.75,-253.5 159.75,-253.5"/>
<text text-anchor="start" x="43.88" y="-233.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Table</text>
<text text-anchor="start" x="78.38" y="-233.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00"> &#160;(struct)</text>
<text text-anchor="start" x="12" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">arity: usize</text>
<text text-anchor="start" x="12" y="-174.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">rows: Vec&lt;Vec&lt;Value&gt;&gt;</text>
</g>
<!-- value_node -->
<g id="node5" class="node">
<title>value_node</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5" d="M351.38,-95C351.38,-95 264.38,-95 264.38,-95 258.38,-95 252.38,-89 252.38,-83 252.38,-83 252.38,-12 252.38,-12 252.38,-6 258.38,0 264.38,0 264.38,0 351.38,0 351.38,0 357.38,0 363.38,-6 363.38,-12 363.38,-12 363.38,-83 363.38,-83 363.38,-89 357.38,-95 351.38,-95"/>
<text text-anchor="start" x="264.38" y="-74.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Value</text>
<text text-anchor="start" x="300.38" y="-74.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"> &#160;(enum)</text>
<text text-anchor="start" x="264.38" y="-44.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Int(i64)</text>
<text text-anchor="start" x="264.38" y="-15.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Str(String)</text>
</g>
<!-- table_node&#45;&gt;value_node -->
<g id="edge3" class="edge">
<title>table_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2" d="M152.48,-158.04C181.05,-137.9 214.32,-114.45 242.73,-94.42"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2" points="244.53,-97.44 250.68,-88.82 240.49,-91.72 244.53,-97.44"/>
<text text-anchor="middle" x="240.66" y="-124.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">Vec&lt;Vec&lt;Value&gt;&gt;</text>
</g>
<!-- relation_node -->
<g id="node2" class="node">
<title>relation_node</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5" d="M381.75,-253.5C381.75,-253.5 234,-253.5 234,-253.5 228,-253.5 222,-247.5 222,-241.5 222,-241.5 222,-170.5 222,-170.5 222,-164.5 228,-158.5 234,-158.5 234,-158.5 381.75,-158.5 381.75,-158.5 387.75,-158.5 393.75,-164.5 393.75,-170.5 393.75,-170.5 393.75,-241.5 393.75,-241.5 393.75,-247.5 387.75,-253.5 381.75,-253.5"/>
<text text-anchor="start" x="256.5" y="-233.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Relation</text>
<text text-anchor="start" x="309.75" y="-233.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00"> &#160;(struct)</text>
<text text-anchor="start" x="234" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">columns: Vec&lt;String&gt;</text>
<text text-anchor="start" x="234" y="-174.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">rows: Vec&lt;Vec&lt;Value&gt;&gt;</text>
</g>
<!-- relation_node&#45;&gt;value_node -->
<g id="edge4" class="edge">
<title>relation_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2" d="M307.88,-158.04C307.88,-141.95 307.88,-123.74 307.88,-106.86"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2" points="311.38,-107.24 307.88,-97.24 304.38,-107.24 311.38,-107.24"/>
<text text-anchor="middle" x="345" y="-124.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">Vec&lt;Vec&lt;Value&gt;&gt;</text>
</g>
<!-- atom_pattern_node -->
<g id="node3" class="node">
<title>atom_pattern_node</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M563.5,-383C563.5,-383 432.25,-383 432.25,-383 426.25,-383 420.25,-377 420.25,-371 420.25,-371 420.25,-329 420.25,-329 420.25,-323 426.25,-317 432.25,-317 432.25,-317 563.5,-317 563.5,-317 569.5,-317 575.5,-323 575.5,-329 575.5,-329 575.5,-371 575.5,-371 575.5,-377 569.5,-383 563.5,-383"/>
<text text-anchor="start" x="432.25" y="-362.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">AtomPattern</text>
<text text-anchor="start" x="514" y="-362.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"> &#160;(struct)</text>
<text text-anchor="start" x="432.25" y="-332.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">columns: Vec&lt;Term&gt;</text>
</g>
<!-- term_node -->
<g id="node4" class="node">
<title>term_node</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M539.88,-253.5C539.88,-253.5 455.88,-253.5 455.88,-253.5 449.88,-253.5 443.88,-247.5 443.88,-241.5 443.88,-241.5 443.88,-170.5 443.88,-170.5 443.88,-164.5 449.88,-158.5 455.88,-158.5 455.88,-158.5 539.88,-158.5 539.88,-158.5 545.88,-158.5 551.88,-164.5 551.88,-170.5 551.88,-170.5 551.88,-241.5 551.88,-241.5 551.88,-247.5 545.88,-253.5 539.88,-253.5"/>
<text text-anchor="start" x="455.88" y="-233.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Term</text>
<text text-anchor="start" x="488.88" y="-233.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00"> &#160;(enum)</text>
<text text-anchor="start" x="455.88" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Var(String)</text>
<text text-anchor="start" x="455.88" y="-174.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">Lit(Value)</text>
</g>
<!-- atom_pattern_node&#45;&gt;term_node -->
<g id="edge1" class="edge">
<title>atom_pattern_node&#45;&gt;term_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2" d="M497.88,-316.78C497.88,-301.61 497.88,-283.04 497.88,-265.52"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2" points="501.38,-265.73 497.88,-255.73 494.38,-265.73 501.38,-265.73"/>
<text text-anchor="middle" x="520.75" y="-283.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">Vec&lt;Term&gt;</text>
</g>
<!-- term_node&#45;&gt;value_node -->
<g id="edge2" class="edge">
<title>term_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2" d="M443.43,-160.15C421.36,-141.97 395.69,-120.83 372.66,-101.87"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2" points="375,-99.25 365.05,-95.6 370.54,-104.65 375,-99.25"/>
<text text-anchor="middle" x="428.07" y="-124.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">Lit(Value)</text>
</g>
</g>
<svg width="584pt" height="420pt"
viewBox="0.00 0.00 583.50 420.00" xmlns="http://www.w3.org/2000/svg">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 416)">
<title>QueryOpsTypes</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-416 579.5,-416 579.5,4 -4,4"/>
<!-- table_node -->
<g id="node1" class="node">
<title>table_node</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M159.75,-282.5C159.75,-282.5 12,-282.5 12,-282.5 6,-282.5 0,-276.5 0,-270.5 0,-270.5 0,-199.5 0,-199.5 0,-193.5 6,-187.5 12,-187.5 12,-187.5 159.75,-187.5 159.75,-187.5 165.75,-187.5 171.75,-193.5 171.75,-199.5 171.75,-199.5 171.75,-270.5 171.75,-270.5 171.75,-276.5 165.75,-282.5 159.75,-282.5"/>
<text text-anchor="start" x="43.88" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Table
</text>
<text text-anchor="start" x="78.38" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(struct)
</text>
<text text-anchor="start" x="12" y="-232.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity: usize
</text>
<text text-anchor="start" x="12" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">rows:
Vec&lt;Vec&lt;Value&gt;&gt;
</text>
</g>
<!-- value_node -->
<g id="node5" class="node">
<title>value_node</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M351.38,-124C351.38,-124 264.38,-124 264.38,-124 258.38,-124 252.38,-118 252.38,-112 252.38,-112 252.38,-12 252.38,-12 252.38,-6 258.38,0 264.38,0 264.38,0 351.38,0 351.38,0 357.38,0 363.38,-6 363.38,-12 363.38,-12 363.38,-112 363.38,-112 363.38,-118 357.38,-124 351.38,-124"/>
<text text-anchor="start" x="264.38" y="-103.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Value
</text>
<text text-anchor="start" x="300.38" y="-103.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(enum)
</text>
<text text-anchor="start" x="264.38" y="-73.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Int(i64)
</text>
<text text-anchor="start" x="264.38" y="-44.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Str(String)
</text>
<text text-anchor="start" x="264.38" y="-15.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Id(RowId)
</text>
</g>
<!-- table_node&#45;&gt;value_node -->
<g id="edge3" class="edge">
<title>table_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M146.83,-187.05C176.61,-164.11 212.47,-136.49 242.78,-113.14"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="244.77,-116.02 250.56,-107.15 240.5,-110.48 244.77,-116.02"/>
<text text-anchor="middle" x="227.34" y="-153.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Vec&lt;Vec&lt;Value&gt;&gt;
</text>
</g>
<!-- relation_node -->
<g id="node2" class="node">
<title>relation_node</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5"
d="M381.75,-282.5C381.75,-282.5 234,-282.5 234,-282.5 228,-282.5 222,-276.5 222,-270.5 222,-270.5 222,-199.5 222,-199.5 222,-193.5 228,-187.5 234,-187.5 234,-187.5 381.75,-187.5 381.75,-187.5 387.75,-187.5 393.75,-193.5 393.75,-199.5 393.75,-199.5 393.75,-270.5 393.75,-270.5 393.75,-276.5 387.75,-282.5 381.75,-282.5"/>
<text text-anchor="start" x="256.5" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Relation
</text>
<text text-anchor="start" x="309.75" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(struct)
</text>
<text text-anchor="start" x="234" y="-232.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
columns: Vec&lt;String&gt;
</text>
<text text-anchor="start" x="234" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
rows: Vec&lt;Vec&lt;Value&gt;&gt;
</text>
</g>
<!-- relation_node&#45;&gt;value_node -->
<g id="edge4" class="edge">
<title>relation_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M307.88,-187.27C307.88,-171.37 307.88,-153.21 307.88,-135.76"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="311.38,-136.25 307.88,-126.25 304.38,-136.25 311.38,-136.25"/>
<text text-anchor="middle" x="345" y="-153.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Vec&lt;Vec&lt;Value&gt;&gt;
</text>
</g>
<!-- atom_pattern_node -->
<g id="node3" class="node">
<title>atom_pattern_node</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M563.5,-412C563.5,-412 432.25,-412 432.25,-412 426.25,-412 420.25,-406 420.25,-400 420.25,-400 420.25,-358 420.25,-358 420.25,-352 426.25,-346 432.25,-346 432.25,-346 563.5,-346 563.5,-346 569.5,-346 575.5,-352 575.5,-358 575.5,-358 575.5,-400 575.5,-400 575.5,-406 569.5,-412 563.5,-412"/>
<text text-anchor="start" x="432.25" y="-391.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">AtomPattern
</text>
<text text-anchor="start" x="514" y="-391.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(struct)
</text>
<text text-anchor="start" x="432.25" y="-361.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
columns: Vec&lt;Term&gt;
</text>
</g>
<!-- term_node -->
<g id="node4" class="node">
<title>term_node</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M539.88,-282.5C539.88,-282.5 455.88,-282.5 455.88,-282.5 449.88,-282.5 443.88,-276.5 443.88,-270.5 443.88,-270.5 443.88,-199.5 443.88,-199.5 443.88,-193.5 449.88,-187.5 455.88,-187.5 455.88,-187.5 539.88,-187.5 539.88,-187.5 545.88,-187.5 551.88,-193.5 551.88,-199.5 551.88,-199.5 551.88,-270.5 551.88,-270.5 551.88,-276.5 545.88,-282.5 539.88,-282.5"/>
<text text-anchor="start" x="455.88" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Term
</text>
<text text-anchor="start" x="488.88" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(enum)
</text>
<text text-anchor="start" x="455.88" y="-232.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Var(String)
</text>
<text text-anchor="start" x="455.88" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Lit(Value)
</text>
</g>
<!-- atom_pattern_node&#45;&gt;term_node -->
<g id="edge1" class="edge">
<title>atom_pattern_node&#45;&gt;term_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M497.88,-345.78C497.88,-330.61 497.88,-312.04 497.88,-294.52"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="501.38,-294.73 497.88,-284.73 494.38,-294.73 501.38,-294.73"/>
<text text-anchor="middle" x="520.75" y="-312.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Vec&lt;Term&gt;
</text>
</g>
<!-- term_node&#45;&gt;value_node -->
<g id="edge2" class="edge">
<title>term_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M445.71,-187.05C423.01,-166.62 396.18,-142.47 372.26,-120.94"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="374.71,-118.44 364.94,-114.35 370.03,-123.65 374.71,-118.44"/>
<text text-anchor="middle" x="433.75" y="-153.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Lit(Value)
</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -1,159 +1,257 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.2.1 (0)
-->
<!-- Title: QueryOpsHandPlan Pages: 1 -->
<svg width="1482pt" height="471pt"
viewBox="0.00 0.00 1481.75 471.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 467)">
<title>QueryOpsHandPlan</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-467 1477.75,-467 1477.75,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_inputs</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2" points="8,-8 8,-455 198.5,-455 198.5,-8 8,-8"/>
<text text-anchor="middle" x="103.25" y="-437.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#555555">Inputs (positional tables)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_atoms</title>
<polygon fill="white" stroke="#9c27b0" stroke-dasharray="5,2" points="233.5,-12 233.5,-451 609.5,-451 609.5,-12 233.5,-12"/>
<text text-anchor="middle" x="421.5" y="-433.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#7b1fa2">Atom Scans &#160;(scan_atom: Table × AtomPattern → Relation)</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_joins</title>
<polygon fill="white" stroke="#4caf50" stroke-dasharray="5,2" points="665.5,-141 665.5,-322 1106,-322 1106,-141 665.5,-141"/>
<text text-anchor="middle" x="885.75" y="-304.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#388e3c">Joins &#160;(shared cols = matching column names)</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_output</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2" points="1141,-152 1141,-311 1465.75,-311 1465.75,-152 1141,-152"/>
<text text-anchor="middle" x="1303.38" y="-293.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00" fill="#555555">Output (binding relation)</text>
</g>
<!-- author_table -->
<g id="node1" class="node">
<title>author_table</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5" d="M165.88,-408.12C165.88,-408.12 40.62,-408.12 40.62,-408.12 34.62,-408.12 28.62,-402.12 28.62,-396.12 28.62,-396.12 28.62,-325.88 28.62,-325.88 28.62,-319.88 34.62,-313.88 40.62,-313.88 40.62,-313.88 165.88,-313.88 165.88,-313.88 171.88,-313.88 177.88,-319.88 177.88,-325.88 177.88,-325.88 177.88,-396.12 177.88,-396.12 177.88,-402.12 171.88,-408.12 165.88,-408.12"/>
<text text-anchor="start" x="60.88" y="-387.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Table: author</text>
<text text-anchor="start" x="40.62" y="-358.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• arity 2</text>
<text text-anchor="start" x="40.62" y="-329.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• rows: (name, book)</text>
</g>
<!-- author_rel -->
<g id="node4" class="node">
<title>author_rel</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M509.12,-408.12C509.12,-408.12 332.88,-408.12 332.88,-408.12 326.88,-408.12 320.88,-402.12 320.88,-396.12 320.88,-396.12 320.88,-325.88 320.88,-325.88 320.88,-319.88 326.88,-313.88 332.88,-313.88 332.88,-313.88 509.12,-313.88 509.12,-313.88 515.12,-313.88 521.12,-319.88 521.12,-325.88 521.12,-325.88 521.12,-396.12 521.12,-396.12 521.12,-402.12 515.12,-408.12 509.12,-408.12"/>
<text text-anchor="start" x="388" y="-387.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">author_rel</text>
<text text-anchor="start" x="332.88" y="-358.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">pattern: [Var name, Var book]</text>
<text text-anchor="start" x="332.88" y="-329.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">cols: [name, book]</text>
</g>
<!-- author_table&#45;&gt;author_rel -->
<g id="edge1" class="edge">
<title>author_table&#45;&gt;author_rel</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M178.28,-361C217.1,-361 265.45,-361 308.68,-361"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2" points="308.62,-364.5 318.62,-361 308.62,-357.5 308.62,-364.5"/>
</g>
<!-- bestseller_table -->
<g id="node2" class="node">
<title>bestseller_table</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5" d="M156.12,-264.12C156.12,-264.12 50.38,-264.12 50.38,-264.12 44.38,-264.12 38.38,-258.12 38.38,-252.12 38.38,-252.12 38.38,-181.88 38.38,-181.88 38.38,-175.88 44.38,-169.88 50.38,-169.88 50.38,-169.88 156.12,-169.88 156.12,-169.88 162.12,-169.88 168.12,-175.88 168.12,-181.88 168.12,-181.88 168.12,-252.12 168.12,-252.12 168.12,-258.12 162.12,-264.12 156.12,-264.12"/>
<text text-anchor="start" x="50.38" y="-243.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Table: bestseller</text>
<text text-anchor="start" x="50.38" y="-214.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• arity 1</text>
<text text-anchor="start" x="50.38" y="-185.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• rows: (book)</text>
</g>
<!-- bestseller_rel -->
<g id="node5" class="node">
<title>bestseller_rel</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M476.12,-264.12C476.12,-264.12 365.88,-264.12 365.88,-264.12 359.88,-264.12 353.88,-258.12 353.88,-252.12 353.88,-252.12 353.88,-181.88 353.88,-181.88 353.88,-175.88 359.88,-169.88 365.88,-169.88 365.88,-169.88 476.12,-169.88 476.12,-169.88 482.12,-169.88 488.12,-175.88 488.12,-181.88 488.12,-181.88 488.12,-252.12 488.12,-252.12 488.12,-258.12 482.12,-264.12 476.12,-264.12"/>
<text text-anchor="start" x="377.5" y="-243.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">bestseller_rel</text>
<text text-anchor="start" x="365.88" y="-214.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">pattern: [Var book]</text>
<text text-anchor="start" x="365.88" y="-185.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">cols: [book]</text>
</g>
<!-- bestseller_table&#45;&gt;bestseller_rel -->
<g id="edge2" class="edge">
<title>bestseller_table&#45;&gt;bestseller_rel</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M168.53,-217C218.65,-217 288.47,-217 341.83,-217"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2" points="341.82,-220.5 351.82,-217 341.82,-213.5 341.82,-220.5"/>
</g>
<!-- price_table -->
<g id="node3" class="node">
<title>price_table</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5" d="M168.5,-120.12C168.5,-120.12 38,-120.12 38,-120.12 32,-120.12 26,-114.12 26,-108.12 26,-108.12 26,-37.88 26,-37.88 26,-31.88 32,-25.88 38,-25.88 38,-25.88 168.5,-25.88 168.5,-25.88 174.5,-25.88 180.5,-31.88 180.5,-37.88 180.5,-37.88 180.5,-108.12 180.5,-108.12 180.5,-114.12 174.5,-120.12 168.5,-120.12"/>
<text text-anchor="start" x="65.75" y="-99.83" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Table: price</text>
<text text-anchor="start" x="38" y="-70.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• arity 2</text>
<text text-anchor="start" x="38" y="-41.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">• rows: (book, dollars)</text>
</g>
<!-- price_rel -->
<g id="node6" class="node">
<title>price_rel</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5" d="M511.75,-120.12C511.75,-120.12 330.25,-120.12 330.25,-120.12 324.25,-120.12 318.25,-114.12 318.25,-108.12 318.25,-108.12 318.25,-37.88 318.25,-37.88 318.25,-31.88 324.25,-25.88 330.25,-25.88 330.25,-25.88 511.75,-25.88 511.75,-25.88 517.75,-25.88 523.75,-31.88 523.75,-37.88 523.75,-37.88 523.75,-108.12 523.75,-108.12 523.75,-114.12 517.75,-120.12 511.75,-120.12"/>
<text text-anchor="start" x="392.88" y="-99.83" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">price_rel</text>
<text text-anchor="start" x="330.25" y="-70.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">pattern: [Var book, Var dollars]</text>
<text text-anchor="start" x="330.25" y="-41.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">cols: [book, dollars]</text>
</g>
<!-- price_table&#45;&gt;price_rel -->
<g id="edge3" class="edge">
<title>price_table&#45;&gt;price_rel</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M180.68,-73C218.39,-73 264.62,-73 306.37,-73"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2" points="306.2,-76.5 316.2,-73 306.2,-69.5 306.2,-76.5"/>
</g>
<!-- semijoin_step -->
<g id="node7" class="node">
<title>semijoin_step</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" d="M819.75,-278.62C819.75,-278.62 691.5,-278.62 691.5,-278.62 685.5,-278.62 679.5,-272.62 679.5,-266.62 679.5,-266.62 679.5,-167.38 679.5,-167.38 679.5,-161.38 685.5,-155.38 691.5,-155.38 691.5,-155.38 819.75,-155.38 819.75,-155.38 825.75,-155.38 831.75,-161.38 831.75,-167.38 831.75,-167.38 831.75,-266.62 831.75,-266.62 831.75,-272.62 825.75,-278.62 819.75,-278.62"/>
<text text-anchor="start" x="727.88" y="-258.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">semijoin</text>
<text text-anchor="start" x="691.5" y="-229.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">authors of bestsellers</text>
<text text-anchor="start" x="691.5" y="-200.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">shared: book</text>
<text text-anchor="start" x="691.5" y="-171.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">cols: [name, book]</text>
</g>
<!-- author_rel&#45;&gt;semijoin_step -->
<g id="edge4" class="edge">
<title>author_rel&#45;&gt;semijoin_step</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" d="M521.48,-324.79C550.11,-313.83 581.24,-301.4 609.5,-289 628.84,-280.51 649.32,-270.81 668.61,-261.33"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2" points="670.15,-264.48 677.56,-256.91 667.04,-258.2 670.15,-264.48"/>
<text text-anchor="middle" x="637.5" y="-284.9" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">left</text>
</g>
<!-- bestseller_rel&#45;&gt;semijoin_step -->
<g id="edge5" class="edge">
<title>bestseller_rel&#45;&gt;semijoin_step</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" d="M488.51,-217C539.93,-217 611.54,-217 667.53,-217"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2" points="667.41,-220.5 677.41,-217 667.41,-213.5 667.41,-220.5"/>
<text text-anchor="middle" x="637.5" y="-221.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">right</text>
</g>
<!-- natural_join_step -->
<g id="node8" class="node">
<title>natural_join_step</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" d="M1080,-278.62C1080,-278.62 922.5,-278.62 922.5,-278.62 916.5,-278.62 910.5,-272.62 910.5,-266.62 910.5,-266.62 910.5,-167.38 910.5,-167.38 910.5,-161.38 916.5,-155.38 922.5,-155.38 922.5,-155.38 1080,-155.38 1080,-155.38 1086,-155.38 1092,-161.38 1092,-167.38 1092,-167.38 1092,-266.62 1092,-266.62 1092,-272.62 1086,-278.62 1080,-278.62"/>
<text text-anchor="start" x="963" y="-258.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">natural_join</text>
<text text-anchor="start" x="922.5" y="-229.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">attach each book&#39;s price</text>
<text text-anchor="start" x="922.5" y="-200.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">shared: book</text>
<text text-anchor="start" x="922.5" y="-171.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">cols: [name, book, dollars]</text>
</g>
<!-- price_rel&#45;&gt;natural_join_step -->
<g id="edge7" class="edge">
<title>price_rel&#45;&gt;natural_join_step</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" d="M523.91,-71.78C608.41,-73.58 730.63,-82.79 831.75,-116.5 855.71,-124.49 879.92,-136.28 902.24,-149"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2" points="900.38,-151.97 910.78,-153.98 903.91,-145.92 900.38,-151.97"/>
<text text-anchor="middle" x="755.62" y="-121.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">right</text>
</g>
<!-- semijoin_step&#45;&gt;natural_join_step -->
<g id="edge6" class="edge">
<title>semijoin_step&#45;&gt;natural_join_step</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" d="M832.04,-217C853.1,-217 876.34,-217 898.65,-217"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2" points="898.4,-220.5 908.4,-217 898.4,-213.5 898.4,-220.5"/>
<text text-anchor="middle" x="871.12" y="-221.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00" fill="#555555">left</text>
</g>
<!-- result -->
<g id="node9" class="node">
<title>result</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5" d="M1435.75,-264.12C1435.75,-264.12 1171,-264.12 1171,-264.12 1165,-264.12 1159,-258.12 1159,-252.12 1159,-252.12 1159,-181.88 1159,-181.88 1159,-175.88 1165,-169.88 1171,-169.88 1171,-169.88 1435.75,-169.88 1435.75,-169.88 1441.75,-169.88 1447.75,-175.88 1447.75,-181.88 1447.75,-181.88 1447.75,-252.12 1447.75,-252.12 1447.75,-258.12 1441.75,-264.12 1435.75,-264.12"/>
<text text-anchor="start" x="1277.5" y="-243.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold" font-size="14.00">Q result</text>
<text text-anchor="start" x="1171" y="-214.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">authors of bestsellers with each book&#39;s price</text>
<text text-anchor="start" x="1171" y="-185.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">cols: [name, book, dollars]</text>
</g>
<!-- natural_join_step&#45;&gt;result -->
<g id="edge8" class="edge">
<title>natural_join_step&#45;&gt;result</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" d="M1092.3,-217C1109.6,-217 1128.17,-217 1146.86,-217"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2" points="1146.69,-220.5 1156.69,-217 1146.69,-213.5 1146.69,-220.5"/>
</g>
</g>
viewBox="0.00 0.00 1481.75 471.00" xmlns="http://www.w3.org/2000/svg">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 467)">
<title>QueryOpsHandPlan</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-467 1477.75,-467 1477.75,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_inputs</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2"
points="8,-8 8,-455 198.5,-455 198.5,-8 8,-8"/>
<text text-anchor="middle" x="103.25" y="-437.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#555555">Inputs (positional tables)
</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_atoms</title>
<polygon fill="white" stroke="#9c27b0" stroke-dasharray="5,2"
points="233.5,-12 233.5,-451 609.5,-451 609.5,-12 233.5,-12"/>
<text text-anchor="middle" x="421.5" y="-433.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#7b1fa2">Atom Scans &#160;(scan_atom: Table × AtomPattern → Relation)
</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_joins</title>
<polygon fill="white" stroke="#4caf50" stroke-dasharray="5,2"
points="665.5,-141 665.5,-322 1106,-322 1106,-141 665.5,-141"/>
<text text-anchor="middle" x="885.75" y="-304.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#388e3c">Joins &#160;(shared cols = matching column names)
</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_output</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2"
points="1141,-152 1141,-311 1465.75,-311 1465.75,-152 1141,-152"/>
<text text-anchor="middle" x="1303.38" y="-293.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#555555">Output (binding relation)
</text>
</g>
<!-- author_table -->
<g id="node1" class="node">
<title>author_table</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M165.88,-408.12C165.88,-408.12 40.62,-408.12 40.62,-408.12 34.62,-408.12 28.62,-402.12 28.62,-396.12 28.62,-396.12 28.62,-325.88 28.62,-325.88 28.62,-319.88 34.62,-313.88 40.62,-313.88 40.62,-313.88 165.88,-313.88 165.88,-313.88 171.88,-313.88 177.88,-319.88 177.88,-325.88 177.88,-325.88 177.88,-396.12 177.88,-396.12 177.88,-402.12 171.88,-408.12 165.88,-408.12"/>
<text text-anchor="start" x="60.88" y="-387.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Table: author
</text>
<text text-anchor="start" x="40.62" y="-358.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity 2
</text>
<text text-anchor="start" x="40.62" y="-329.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
rows: (name, book)
</text>
</g>
<!-- author_rel -->
<g id="node4" class="node">
<title>author_rel</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M509.12,-408.12C509.12,-408.12 332.88,-408.12 332.88,-408.12 326.88,-408.12 320.88,-402.12 320.88,-396.12 320.88,-396.12 320.88,-325.88 320.88,-325.88 320.88,-319.88 326.88,-313.88 332.88,-313.88 332.88,-313.88 509.12,-313.88 509.12,-313.88 515.12,-313.88 521.12,-319.88 521.12,-325.88 521.12,-325.88 521.12,-396.12 521.12,-396.12 521.12,-402.12 515.12,-408.12 509.12,-408.12"/>
<text text-anchor="start" x="388" y="-387.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">author_rel
</text>
<text text-anchor="start" x="332.88" y="-358.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
pattern: [Var name, Var book]
</text>
<text text-anchor="start" x="332.88" y="-329.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
cols: [name, book]
</text>
</g>
<!-- author_table&#45;&gt;author_rel -->
<g id="edge1" class="edge">
<title>author_table&#45;&gt;author_rel</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M178.28,-361C217.1,-361 265.45,-361 308.68,-361"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="308.62,-364.5 318.62,-361 308.62,-357.5 308.62,-364.5"/>
</g>
<!-- bestseller_table -->
<g id="node2" class="node">
<title>bestseller_table</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M156.12,-264.12C156.12,-264.12 50.38,-264.12 50.38,-264.12 44.38,-264.12 38.38,-258.12 38.38,-252.12 38.38,-252.12 38.38,-181.88 38.38,-181.88 38.38,-175.88 44.38,-169.88 50.38,-169.88 50.38,-169.88 156.12,-169.88 156.12,-169.88 162.12,-169.88 168.12,-175.88 168.12,-181.88 168.12,-181.88 168.12,-252.12 168.12,-252.12 168.12,-258.12 162.12,-264.12 156.12,-264.12"/>
<text text-anchor="start" x="50.38" y="-243.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Table: bestseller
</text>
<text text-anchor="start" x="50.38" y="-214.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity 1
</text>
<text text-anchor="start" x="50.38" y="-185.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
rows: (book)
</text>
</g>
<!-- bestseller_rel -->
<g id="node5" class="node">
<title>bestseller_rel</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M476.12,-264.12C476.12,-264.12 365.88,-264.12 365.88,-264.12 359.88,-264.12 353.88,-258.12 353.88,-252.12 353.88,-252.12 353.88,-181.88 353.88,-181.88 353.88,-175.88 359.88,-169.88 365.88,-169.88 365.88,-169.88 476.12,-169.88 476.12,-169.88 482.12,-169.88 488.12,-175.88 488.12,-181.88 488.12,-181.88 488.12,-252.12 488.12,-252.12 488.12,-258.12 482.12,-264.12 476.12,-264.12"/>
<text text-anchor="start" x="377.5" y="-243.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">bestseller_rel
</text>
<text text-anchor="start" x="365.88" y="-214.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
pattern: [Var book]
</text>
<text text-anchor="start" x="365.88" y="-185.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
cols: [book]
</text>
</g>
<!-- bestseller_table&#45;&gt;bestseller_rel -->
<g id="edge2" class="edge">
<title>bestseller_table&#45;&gt;bestseller_rel</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M168.53,-217C218.65,-217 288.47,-217 341.83,-217"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="341.82,-220.5 351.82,-217 341.82,-213.5 341.82,-220.5"/>
</g>
<!-- price_table -->
<g id="node3" class="node">
<title>price_table</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M168.5,-120.12C168.5,-120.12 38,-120.12 38,-120.12 32,-120.12 26,-114.12 26,-108.12 26,-108.12 26,-37.88 26,-37.88 26,-31.88 32,-25.88 38,-25.88 38,-25.88 168.5,-25.88 168.5,-25.88 174.5,-25.88 180.5,-31.88 180.5,-37.88 180.5,-37.88 180.5,-108.12 180.5,-108.12 180.5,-114.12 174.5,-120.12 168.5,-120.12"/>
<text text-anchor="start" x="65.75" y="-99.83" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Table: price
</text>
<text text-anchor="start" x="38" y="-70.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity 2
</text>
<text text-anchor="start" x="38" y="-41.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
rows: (book, dollars)
</text>
</g>
<!-- price_rel -->
<g id="node6" class="node">
<title>price_rel</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M511.75,-120.12C511.75,-120.12 330.25,-120.12 330.25,-120.12 324.25,-120.12 318.25,-114.12 318.25,-108.12 318.25,-108.12 318.25,-37.88 318.25,-37.88 318.25,-31.88 324.25,-25.88 330.25,-25.88 330.25,-25.88 511.75,-25.88 511.75,-25.88 517.75,-25.88 523.75,-31.88 523.75,-37.88 523.75,-37.88 523.75,-108.12 523.75,-108.12 523.75,-114.12 517.75,-120.12 511.75,-120.12"/>
<text text-anchor="start" x="392.88" y="-99.83" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">price_rel
</text>
<text text-anchor="start" x="330.25" y="-70.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
pattern: [Var book, Var dollars]
</text>
<text text-anchor="start" x="330.25" y="-41.58" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
cols: [book, dollars]
</text>
</g>
<!-- price_table&#45;&gt;price_rel -->
<g id="edge3" class="edge">
<title>price_table&#45;&gt;price_rel</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" d="M180.68,-73C218.39,-73 264.62,-73 306.37,-73"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="306.2,-76.5 316.2,-73 306.2,-69.5 306.2,-76.5"/>
</g>
<!-- semijoin_step -->
<g id="node7" class="node">
<title>semijoin_step</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M819.75,-278.62C819.75,-278.62 691.5,-278.62 691.5,-278.62 685.5,-278.62 679.5,-272.62 679.5,-266.62 679.5,-266.62 679.5,-167.38 679.5,-167.38 679.5,-161.38 685.5,-155.38 691.5,-155.38 691.5,-155.38 819.75,-155.38 819.75,-155.38 825.75,-155.38 831.75,-161.38 831.75,-167.38 831.75,-167.38 831.75,-266.62 831.75,-266.62 831.75,-272.62 825.75,-278.62 819.75,-278.62"/>
<text text-anchor="start" x="727.88" y="-258.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">semijoin
</text>
<text text-anchor="start" x="691.5" y="-229.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
authors of bestsellers
</text>
<text text-anchor="start" x="691.5" y="-200.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
shared: book
</text>
<text text-anchor="start" x="691.5" y="-171.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
cols: [name, book]
</text>
</g>
<!-- author_rel&#45;&gt;semijoin_step -->
<g id="edge4" class="edge">
<title>author_rel&#45;&gt;semijoin_step</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2"
d="M521.48,-324.79C550.11,-313.83 581.24,-301.4 609.5,-289 628.84,-280.51 649.32,-270.81 668.61,-261.33"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="670.15,-264.48 677.56,-256.91 667.04,-258.2 670.15,-264.48"/>
<text text-anchor="middle" x="637.5" y="-284.9" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">left
</text>
</g>
<!-- bestseller_rel&#45;&gt;semijoin_step -->
<g id="edge5" class="edge">
<title>bestseller_rel&#45;&gt;semijoin_step</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" d="M488.51,-217C539.93,-217 611.54,-217 667.53,-217"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="667.41,-220.5 677.41,-217 667.41,-213.5 667.41,-220.5"/>
<text text-anchor="middle" x="637.5" y="-221.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">right
</text>
</g>
<!-- natural_join_step -->
<g id="node8" class="node">
<title>natural_join_step</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1080,-278.62C1080,-278.62 922.5,-278.62 922.5,-278.62 916.5,-278.62 910.5,-272.62 910.5,-266.62 910.5,-266.62 910.5,-167.38 910.5,-167.38 910.5,-161.38 916.5,-155.38 922.5,-155.38 922.5,-155.38 1080,-155.38 1080,-155.38 1086,-155.38 1092,-161.38 1092,-167.38 1092,-167.38 1092,-266.62 1092,-266.62 1092,-272.62 1086,-278.62 1080,-278.62"/>
<text text-anchor="start" x="963" y="-258.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">natural_join
</text>
<text text-anchor="start" x="922.5" y="-229.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
attach each book&#39;s price
</text>
<text text-anchor="start" x="922.5" y="-200.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
shared: book
</text>
<text text-anchor="start" x="922.5" y="-171.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
cols: [name, book, dollars]
</text>
</g>
<!-- price_rel&#45;&gt;natural_join_step -->
<g id="edge7" class="edge">
<title>price_rel&#45;&gt;natural_join_step</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2"
d="M523.91,-71.78C608.41,-73.58 730.63,-82.79 831.75,-116.5 855.71,-124.49 879.92,-136.28 902.24,-149"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="900.38,-151.97 910.78,-153.98 903.91,-145.92 900.38,-151.97"/>
<text text-anchor="middle" x="755.62" y="-121.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">right
</text>
</g>
<!-- semijoin_step&#45;&gt;natural_join_step -->
<g id="edge6" class="edge">
<title>semijoin_step&#45;&gt;natural_join_step</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" d="M832.04,-217C853.1,-217 876.34,-217 898.65,-217"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="898.4,-220.5 908.4,-217 898.4,-213.5 898.4,-220.5"/>
<text text-anchor="middle" x="871.12" y="-221.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">left
</text>
</g>
<!-- result -->
<g id="node9" class="node">
<title>result</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5"
d="M1435.75,-264.12C1435.75,-264.12 1171,-264.12 1171,-264.12 1165,-264.12 1159,-258.12 1159,-252.12 1159,-252.12 1159,-181.88 1159,-181.88 1159,-175.88 1165,-169.88 1171,-169.88 1171,-169.88 1435.75,-169.88 1435.75,-169.88 1441.75,-169.88 1447.75,-175.88 1447.75,-181.88 1447.75,-181.88 1447.75,-252.12 1447.75,-252.12 1447.75,-258.12 1441.75,-264.12 1435.75,-264.12"/>
<text text-anchor="start" x="1277.5" y="-243.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Q result
</text>
<text text-anchor="start" x="1171" y="-214.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
authors of bestsellers with each book&#39;s price
</text>
<text text-anchor="start" x="1171" y="-185.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
cols: [name, book, dollars]
</text>
</g>
<!-- natural_join_step&#45;&gt;result -->
<g id="edge8" class="edge">
<title>natural_join_step&#45;&gt;result</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M1092.3,-217C1109.6,-217 1128.17,-217 1146.86,-217"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1146.69,-220.5 1156.69,-217 1146.69,-213.5 1146.69,-220.5"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -9,8 +9,8 @@
use std::collections::HashMap;
use query_storage::table::Table;
use query_storage::value::Value;
use storage::table::Table;
use storage::value::Value;
use crate::relation::Relation;

View File

@ -11,7 +11,7 @@
use std::collections::{HashMap, HashSet};
use query_storage::value::Value;
use storage::value::Value;
use crate::relation::Relation;

View File

@ -2,7 +2,7 @@
//!
//! Three operators are in scope:
//!
//! - [`atom::scan_atom`] scans a [`Table`](query_storage::table::Table) under
//! - [`atom::scan_atom`] scans a [`Table`](storage::table::Table) under
//! an [`atom::AtomPattern`], filtering for repeated-variable equality and
//! literal equality, and outputs a binding [`relation::Relation`].
//! - [`join::semijoin`] keeps rows of one relation whose shared-column values
@ -14,8 +14,8 @@
//! is just an expression like
//! `natural_join(&semijoin(&a, &b), &scan_atom(&t, &p))`.
//!
//! Foundational types [`Value`](query_storage::value::Value) and
//! [`Table`](query_storage::table::Table) live in `query-storage`, the
//! Foundational types [`Value`](storage::value::Value) and
//! [`Table`](storage::table::Table) live in `storage`, the
//! storage-layer crate this crate is built on; storage backends produce
//! `Table`s that operators here consume.

View File

@ -10,7 +10,7 @@
use std::collections::HashSet;
use query_storage::value::Value;
use storage::value::Value;
#[derive(Debug, Clone)]
pub struct Relation {

View File

@ -15,8 +15,8 @@
use query_ops::atom::{AtomPattern, Term, scan_atom};
use query_ops::join::{natural_join, semijoin};
use query_storage::table::Table;
use query_storage::value::Value;
use storage::table::Table;
use storage::value::Value;
fn s(x: &str) -> Value {
Value::Str(x.to_string())

View File

@ -5,9 +5,9 @@
//! through the [`scan_as_table`] bridge, with no changes to `query-ops` itself.
use query_ops::atom::{AtomPattern, Term, scan_atom};
use query_storage::table::Table;
use query_storage::value::Value;
use query_storage::{MemoryStorage, Storage, StorageError, scan_as_table};
use storage::table::Table;
use storage::value::Value;
use storage::{MemoryStorage, Storage, StorageError, scan_as_table};
fn i(x: i64) -> Value {
Value::Int(x)

View File

@ -1,149 +0,0 @@
//! fjall adapter.
//!
//! Each relation gets a fjall [`PartitionHandle`](fjall::PartitionHandle) of
//! the same name. A reserved partition named `__meta` carries per-relation
//! metadata (arity and next synthetic row ID).
use fjall::{Keyspace, PartitionCreateOptions, PartitionHandle};
use crate::value::Value;
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row, row_key};
use crate::{Storage, StorageError};
const META_PARTITION: &str = "__meta";
fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> StorageError {
StorageError::Backend(Box::new(err))
}
/// fjall-backed [`Storage`] implementation.
pub struct FjallStorage {
keyspace: Keyspace,
meta: PartitionHandle,
}
impl FjallStorage {
/// Open or create a fjall keyspace at `path`.
///
/// # Errors
/// Returns [`StorageError::Backend`] if fjall fails to open the path.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
let keyspace = fjall::Config::new(path).open().map_err(backend)?;
let meta = keyspace
.open_partition(META_PARTITION, PartitionCreateOptions::default())
.map_err(backend)?;
Ok(Self { keyspace, meta })
}
fn relation_partition(&self, name: &str) -> Result<PartitionHandle, StorageError> {
self.keyspace
.open_partition(name, PartitionCreateOptions::default())
.map_err(backend)
}
fn load_meta(&self, name: &str) -> Result<(u32, u64), StorageError> {
let raw = self
.meta
.get(name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
Ok(decode_meta(raw.as_ref())?)
}
fn store_meta(&self, name: &str, arity: u32, next_id: u64) -> Result<(), StorageError> {
self.meta
.insert(name.as_bytes(), encode_meta(arity, next_id))
.map_err(backend)?;
Ok(())
}
}
impl Storage for FjallStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_PARTITION {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
if self.meta.contains_key(name.as_bytes()).map_err(backend)? {
return Err(StorageError::RelationExists(name.to_string()));
}
let arity_u32 = u32::try_from(arity)
.map_err(|_| StorageError::Validation(format!("arity {arity} exceeds u32 range")))?;
self.store_meta(name, arity_u32, 0)?;
let _ = self.relation_partition(name)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let (arity, _) = self.load_meta(name)?;
Ok(arity as usize)
}
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError> {
let _ = self.load_meta(name)?;
let partition = self.relation_partition(name)?;
let mut rows = Vec::new();
for entry in partition.iter() {
let (_, value) = entry.map_err(backend)?;
rows.push(decode_row(value.as_ref())?);
}
Ok(rows)
}
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError> {
let (arity, next_id) = self.load_meta(name)?;
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
let partition = self.relation_partition(name)?;
partition
.insert(row_key(next_id), encode_row(&row))
.map_err(backend)?;
self.store_meta(name, arity, next_id + 1)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn i(x: i64) -> Value {
Value::Int(x)
}
fn open_temp() -> Result<FjallStorage, StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let storage = FjallStorage::open(dir.path())?;
std::mem::forget(dir);
Ok(storage)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![vec![i(1), i(2)], vec![i(2), i(3)]]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
}

View File

@ -1,252 +0,0 @@
//! Geomerge adapter.
//!
//! Unlike the other backends, geomerge schemas are **immutable after store
//! construction**: there is no public API to register a new table on a live
//! `Store`. The adapter therefore expects all relations to be declared up
//! front via a `FlatTheory` passed to [`GeomergeStorage::from_theory`], and
//! [`Storage::create_relation`] becomes a verifier that the relation exists
//! in the loaded theory and that its arity matches.
//!
//! Additional v1 mismatches with the trait:
//!
//! - Column types are typed (`PrimInt` / `PrimString`) in geomerge but the
//! trait's `create_relation` only carries `arity`. The adapter cannot
//! declare a relation at runtime, so this issue surfaces only at insert
//! time when geomerge rejects a row with `StorageError::Validation`.
//! - Cells of type `CellValue::Id` cannot be represented in our `Value` enum.
//! Scanning a table that contains such cells returns `StorageError::Validation`.
//! - Every `insert` opens a fresh `Transaction` and commits. Law violations
//! surface at commit time, not at the `add` call.
use std::collections::HashSet;
use geomerge::ir::{self, Path};
use geomerge::store::Store;
use geomerge::table::CellValue;
use geomerge::txn::ops::TxnCellValue;
use crate::value::Value;
use crate::{Storage, StorageError};
fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> StorageError {
StorageError::Backend(Box::new(err))
}
fn validation(msg: impl Into<String>) -> StorageError {
StorageError::Validation(msg.into())
}
/// Geomerge-backed [`Storage`] implementation.
///
/// Construct via [`GeomergeStorage::new`] (empty store, no relations) or
/// [`GeomergeStorage::from_theory`] (preloaded with a `FlatTheory`).
pub struct GeomergeStorage {
store: Store,
declared: HashSet<String>,
}
impl Default for GeomergeStorage {
fn default() -> Self {
Self::new()
}
}
impl GeomergeStorage {
/// Build an empty store. No relations are available until the store is
/// rebuilt via a theory.
#[must_use]
pub fn new() -> Self {
Self {
store: Store::new(),
declared: HashSet::new(),
}
}
/// Build a store from a pre-defined `FlatTheory`. All `create_relation`
/// calls must reference relations declared in the theory.
///
/// # Errors
/// Returns [`StorageError::Backend`] if geomerge rejects the theory.
pub fn from_theory(theory: ir::FlatTheory) -> Result<Self, StorageError> {
let store = Store::try_from_theory(theory).map_err(|e| backend(*e))?;
Ok(Self {
store,
declared: HashSet::new(),
})
}
}
impl Storage for GeomergeStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if self.declared.contains(name) {
return Err(StorageError::RelationExists(name.to_string()));
}
let path: Path = name.into();
let table = self.store.table_at(&path).ok_or_else(|| {
validation(format!(
"relation '{name}' is not declared in the loaded geomerge theory; \
geomerge does not support runtime relation creation"
))
})?;
let declared_arity = table.schema().columns.len();
if declared_arity != arity {
return Err(StorageError::ArityMismatch {
expected: declared_arity,
got: arity,
});
}
self.declared.insert(name.to_string());
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let path: Path = name.into();
let table = self
.store
.table_at(&path)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
Ok(table.schema().columns.len())
}
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError> {
let path: Path = name.into();
let table = self
.store
.table_at(&path)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let arity = table.schema().columns.len();
let mut rows = Vec::with_capacity(table.row_count());
for r in 0..table.row_count() {
let mut row = Vec::with_capacity(arity);
for c in 0..arity {
let cell = table
.cell_at(r, c)
.ok_or_else(|| validation(format!("missing cell at ({r}, {c}) in '{name}'")))?;
row.push(cell_to_value(cell)?);
}
rows.push(row);
}
Ok(rows)
}
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError> {
let path: Path = name.into();
let arity = self.arity(name)?;
if row.len() != arity {
return Err(StorageError::ArityMismatch {
expected: arity,
got: row.len(),
});
}
let values: Vec<TxnCellValue> = row.into_iter().map(value_to_txn_cell).collect();
let mut txn = self.store.transaction();
txn.add(&path, values)
.map_err(|e| validation(e.to_string()))?;
// Law violations surface here at commit time, not at add time.
txn.commit().map_err(|e| validation(e.to_string()))?;
Ok(())
}
}
fn cell_to_value(cell: &CellValue) -> Result<Value, StorageError> {
match cell {
CellValue::Int(i) => Ok(Value::Int(*i)),
CellValue::Str(s) => Ok(Value::Str(s.clone())),
CellValue::Id(_) => Err(validation(
"geomerge CellValue::Id cannot be represented in the playground's Value enum",
)),
}
}
fn value_to_txn_cell(value: Value) -> TxnCellValue {
match value {
Value::Int(i) => TxnCellValue::Int(i),
Value::Str(s) => TxnCellValue::Str(s),
}
}
#[cfg(test)]
mod tests {
use super::*;
use geomerge::ir::{ColType, FlatTheory, PrimType, Schema, TableEntry};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn int_schema(arity: usize) -> Schema {
Schema {
columns: (0..arity)
.map(|_| ColType::PrimType {
prim: PrimType::PrimInt,
})
.collect(),
primary_key: None,
}
}
fn theory_with_one_int_table(name: &str, arity: usize) -> FlatTheory {
FlatTheory {
tables: vec![TableEntry {
path: name.into(),
table: int_schema(arity),
}],
laws: Vec::new(),
}
}
#[test]
fn empty_store_has_no_relations() {
let storage = GeomergeStorage::new();
assert!(matches!(
storage.arity("edge"),
Err(StorageError::RelationNotFound(_))
));
}
#[test]
fn create_relation_on_undeclared_returns_validation_error() {
let mut storage = GeomergeStorage::new();
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::Validation(_))
));
}
#[test]
fn theory_loaded_insert_scan_roundtrip() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![i(3), i(4)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![vec![i(1), i(2)], vec![i(3), i(4)]]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn insert_wrong_type_returns_validation_error() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
// Insert a Str into an Int column: geomerge rejects it.
let result = storage.insert("edge", vec![Value::Str("not an int".to_string()), i(2)]);
assert!(matches!(result, Err(StorageError::Validation(_))));
Ok(())
}
}

View File

@ -1,145 +0,0 @@
//! Storage layer for the query-plan playground.
//!
//! This is the foundational crate of the workspace. It owns the [`Value`] cell
//! type and the [`Table`] container, defines the [`Storage`] trait, and ships
//! adapters for several backends behind Cargo features. Higher-level crates
//! such as `query-ops` depend on this crate for both the types and the trait.
//!
//! The v1 trait surface is deliberately narrow: create a relation, scan all
//! rows, insert a row, ask for arity. Transactions, range scans, deletes, and
//! delta streams are not modeled yet, and will be added when a specific
//! experiment demands them.
//!
//! ## Backends
//!
//! [`MemoryStorage`] is always available. Other backends are gated behind
//! Cargo features so users only pay for what they need:
//!
//! - `lmdb` — LMDB via the `heed` crate
//! - `redb` — pure-Rust embedded KV
//! - `fjall` — pure-Rust LSM-tree
//! - `sled` — pure-Rust LSM-tree
//! - `geomerge` — the workspace's `geomerge` crate
use crate::table::Table;
use crate::value::Value;
pub mod codec;
pub mod memory;
pub mod table;
pub mod value;
#[cfg(feature = "sled")]
pub mod sled;
#[cfg(feature = "redb")]
pub mod redb;
#[cfg(feature = "fjall")]
pub mod fjall;
#[cfg(feature = "lmdb")]
pub mod lmdb;
#[cfg(feature = "geomerge")]
pub mod geomerge;
pub use memory::MemoryStorage;
/// Errors returned by a [`Storage`] backend.
///
/// Backend-specific failures (LMDB transaction aborts, sled I/O errors, etc.)
/// are wrapped in [`StorageError::Backend`].
#[derive(Debug)]
pub enum StorageError {
/// No relation with the given name exists in this backend.
RelationNotFound(String),
/// A relation with the given name already exists.
RelationExists(String),
/// A row was offered with the wrong number of columns.
ArityMismatch { expected: usize, got: usize },
/// A backend-defined validation rule rejected the operation, for example
/// a `geomerge` law violation.
Validation(String),
/// A row decoded from storage was malformed.
Decode(codec::CodecError),
/// A backend-specific error wrapped for transport across the trait.
Backend(Box<dyn std::error::Error + Send + Sync>),
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RelationNotFound(name) => write!(f, "relation not found: {name}"),
Self::RelationExists(name) => write!(f, "relation already exists: {name}"),
Self::ArityMismatch { expected, got } => {
write!(f, "arity mismatch: expected {expected}, got {got}")
}
Self::Validation(msg) => write!(f, "validation failed: {msg}"),
Self::Decode(err) => write!(f, "decode error: {err}"),
Self::Backend(err) => write!(f, "backend error: {err}"),
}
}
}
impl std::error::Error for StorageError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Backend(err) => Some(err.as_ref()),
Self::Decode(err) => Some(err),
_ => None,
}
}
}
impl From<codec::CodecError> for StorageError {
fn from(err: codec::CodecError) -> Self {
Self::Decode(err)
}
}
/// Backend-agnostic interface for storing and retrieving rows.
///
/// Each relation has a fixed name, a fixed arity (row width), and an ordered
/// collection of rows whose cells are [`Value`]s. Concrete implementations
/// include [`MemoryStorage`] in this crate plus the feature-gated backends.
pub trait Storage {
/// Create a new relation with the given name and arity.
///
/// # Errors
/// Returns [`StorageError::RelationExists`] if a relation with the given
/// name already exists.
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError>;
/// Return the arity of the given relation.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists.
fn arity(&self, name: &str) -> Result<usize, StorageError>;
/// Scan all rows of the given relation in storage order.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists.
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError>;
/// Append a row to the given relation.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists,
/// [`StorageError::ArityMismatch`] if the row's length differs from the
/// declared arity, or [`StorageError::Validation`] / [`StorageError::Backend`]
/// if a backend-specific rule rejects the row.
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError>;
}
/// Materialize a relation from a [`Storage`] backend as a [`Table`] that
/// query-language operators can consume.
///
/// # Errors
/// Returns any error produced by [`Storage::arity`] or [`Storage::scan`].
pub fn scan_as_table(storage: &dyn Storage, name: &str) -> Result<Table, StorageError> {
let arity = storage.arity(name)?;
let rows = storage.scan(name)?;
Ok(Table::from_rows(arity, rows))
}

View File

@ -1,201 +0,0 @@
//! LMDB adapter via the `heed` crate.
//!
//! Maps each relation onto a named LMDB sub-database of the same name. A
//! reserved sub-database named `__meta` carries per-relation metadata (arity
//! and next synthetic row ID).
//!
//! Note: every [`Storage::insert`] opens its own write transaction. LMDB
//! serializes writers across the env, so per-row inserts will be slow on real
//! workloads. The v1 trait does not yet expose batch inserts.
use heed::types::Bytes;
use heed::{Database, Env, EnvOpenOptions};
use crate::value::Value;
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row, row_key};
use crate::{Storage, StorageError};
const META_DB: &str = "__meta";
const DEFAULT_MAX_DBS: u32 = 128;
const DEFAULT_MAP_SIZE: usize = 100 * 1024 * 1024;
fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> StorageError {
StorageError::Backend(Box::new(err))
}
/// LMDB-backed [`Storage`] implementation.
pub struct LmdbStorage {
env: Env,
meta: Database<Bytes, Bytes>,
}
impl LmdbStorage {
/// Open or create an LMDB environment at `path`.
///
/// The path must already exist as a directory; LMDB will create its data
/// files inside it.
///
/// # Errors
/// Returns [`StorageError::Backend`] if LMDB fails to open.
///
/// # Safety
/// Internally uses `EnvOpenOptions::open`, which heed marks `unsafe`
/// because the memory-mapped file's contents can be modified by other
/// processes. The adapter assumes single-process exclusive access.
#[allow(unsafe_code)]
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
// SAFETY: heed marks `open` unsafe because the mmap'd file's contents
// can be modified by other processes, violating Rust's aliasing rules.
// This adapter assumes single-process exclusive access to the path,
// which holds for tests and typical playground use.
let env = unsafe {
EnvOpenOptions::new()
.max_dbs(DEFAULT_MAX_DBS)
.map_size(DEFAULT_MAP_SIZE)
.open(path)
.map_err(backend)?
};
let mut wtxn = env.write_txn().map_err(backend)?;
let meta: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some(META_DB))
.map_err(backend)?;
wtxn.commit().map_err(backend)?;
Ok(Self { env, meta })
}
fn open_relation_db(
&self,
wtxn: &mut heed::RwTxn,
name: &str,
) -> Result<Database<Bytes, Bytes>, StorageError> {
self.env.create_database(wtxn, Some(name)).map_err(backend)
}
}
impl Storage for LmdbStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_DB {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
let arity_u32 = u32::try_from(arity)
.map_err(|_| StorageError::Validation(format!("arity {arity} exceeds u32 range")))?;
let mut wtxn = self.env.write_txn().map_err(backend)?;
if self
.meta
.get(&wtxn, name.as_bytes())
.map_err(backend)?
.is_some()
{
return Err(StorageError::RelationExists(name.to_string()));
}
let encoded = encode_meta(arity_u32, 0);
self.meta
.put(&mut wtxn, name.as_bytes(), &encoded[..])
.map_err(backend)?;
let _ = self.open_relation_db(&mut wtxn, name)?;
wtxn.commit().map_err(backend)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let rtxn = self.env.read_txn().map_err(backend)?;
let raw = self
.meta
.get(&rtxn, name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let (arity, _) = decode_meta(raw)?;
Ok(arity as usize)
}
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError> {
let rtxn = self.env.read_txn().map_err(backend)?;
if self
.meta
.get(&rtxn, name.as_bytes())
.map_err(backend)?
.is_none()
{
return Err(StorageError::RelationNotFound(name.to_string()));
}
let db: Database<Bytes, Bytes> = self
.env
.open_database(&rtxn, Some(name))
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let mut rows = Vec::new();
for entry in db.iter(&rtxn).map_err(backend)? {
let (_, value) = entry.map_err(backend)?;
rows.push(decode_row(value)?);
}
Ok(rows)
}
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError> {
let mut wtxn = self.env.write_txn().map_err(backend)?;
let meta_bytes = self
.meta
.get(&wtxn, name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let (arity, next_id) = decode_meta(meta_bytes)?;
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
let db = self.open_relation_db(&mut wtxn, name)?;
let key = row_key(next_id);
let value = encode_row(&row);
db.put(&mut wtxn, &key[..], &value[..]).map_err(backend)?;
let new_meta = encode_meta(arity, next_id + 1);
self.meta
.put(&mut wtxn, name.as_bytes(), &new_meta[..])
.map_err(backend)?;
wtxn.commit().map_err(backend)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn i(x: i64) -> Value {
Value::Int(x)
}
fn open_temp() -> Result<LmdbStorage, StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let storage = LmdbStorage::open(dir.path())?;
std::mem::forget(dir);
Ok(storage)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![vec![i(1), i(2)], vec![i(2), i(3)]]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
}

View File

@ -1,147 +0,0 @@
//! In-memory backend, keyed by relation name. Always available.
use std::collections::HashMap;
use crate::value::Value;
use crate::{Storage, StorageError};
/// In-memory backend, useful as the default in tests and as a correctness
/// oracle for other backends.
#[derive(Debug, Default)]
pub struct MemoryStorage {
relations: HashMap<String, MemoryRelation>,
}
#[derive(Debug)]
struct MemoryRelation {
arity: usize,
rows: Vec<Vec<Value>>,
}
impl MemoryStorage {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl Storage for MemoryStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if self.relations.contains_key(name) {
return Err(StorageError::RelationExists(name.to_string()));
}
self.relations.insert(
name.to_string(),
MemoryRelation {
arity,
rows: Vec::new(),
},
);
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
self.relations
.get(name)
.map(|r| r.arity)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))
}
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError> {
self.relations
.get(name)
.map(|r| r.rows.clone())
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))
}
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError> {
let relation = self
.relations
.get_mut(name)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
if row.len() != relation.arity {
return Err(StorageError::ArityMismatch {
expected: relation.arity,
got: row.len(),
});
}
relation.rows.push(row);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan_as_table;
fn i(x: i64) -> Value {
Value::Int(x)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![vec![i(1), i(2)], vec![i(2), i(3)]]);
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn scan_unknown_relation_returns_err() {
let storage = MemoryStorage::new();
assert!(matches!(
storage.scan("missing"),
Err(StorageError::RelationNotFound(_))
));
}
#[test]
fn arity_unknown_relation_returns_err() {
let storage = MemoryStorage::new();
assert!(matches!(
storage.arity("missing"),
Err(StorageError::RelationNotFound(_))
));
}
#[test]
fn insert_wrong_arity_returns_err() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.insert("edge", vec![i(1)]),
Err(StorageError::ArityMismatch {
expected: 2,
got: 1
})
));
Ok(())
}
#[test]
fn scan_as_table_materializes_table() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
let table = scan_as_table(&storage, "edge")?;
assert_eq!(table.arity, 2);
assert_eq!(table.rows, vec![vec![i(1), i(2)]]);
Ok(())
}
}

View File

@ -1,183 +0,0 @@
//! redb adapter.
//!
//! Each relation gets a redb table named after it, keyed by `u64` row IDs.
//! A reserved table named `__meta`, keyed by relation name, carries per-relation
//! metadata (arity and next synthetic row ID).
use redb::{Database, ReadableTable, TableDefinition};
use crate::value::Value;
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row};
use crate::{Storage, StorageError};
const META_TABLE: &str = "__meta";
fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> StorageError {
StorageError::Backend(Box::new(err))
}
fn meta_def() -> TableDefinition<'static, &'static str, &'static [u8]> {
TableDefinition::new(META_TABLE)
}
fn rows_def(name: &str) -> TableDefinition<'_, u64, &'static [u8]> {
TableDefinition::new(name)
}
/// redb-backed [`Storage`] implementation.
pub struct RedbStorage {
db: Database,
}
impl RedbStorage {
/// Open or create a redb database at `path`.
///
/// # Errors
/// Returns [`StorageError::Backend`] if redb fails to open the file.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
let db = Database::create(path).map_err(backend)?;
Ok(Self { db })
}
}
impl Storage for RedbStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_TABLE {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
let arity_u32 = u32::try_from(arity)
.map_err(|_| StorageError::Validation(format!("arity {arity} exceeds u32 range")))?;
let txn = self.db.begin_write().map_err(backend)?;
{
let mut meta = txn.open_table(meta_def()).map_err(backend)?;
if meta.get(name).map_err(backend)?.is_some() {
return Err(StorageError::RelationExists(name.to_string()));
}
let encoded = encode_meta(arity_u32, 0);
meta.insert(name, &encoded[..]).map_err(backend)?;
// open_table creates the rows table if it does not exist
let _ = txn.open_table(rows_def(name)).map_err(backend)?;
}
txn.commit().map_err(backend)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let txn = self.db.begin_read().map_err(backend)?;
let meta = txn.open_table(meta_def()).map_err(backend)?;
let raw = meta
.get(name)
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let (arity, _) = decode_meta(raw.value())?;
Ok(arity as usize)
}
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError> {
let txn = self.db.begin_read().map_err(backend)?;
let meta = txn.open_table(meta_def()).map_err(backend)?;
if meta.get(name).map_err(backend)?.is_none() {
return Err(StorageError::RelationNotFound(name.to_string()));
}
let table = txn.open_table(rows_def(name)).map_err(backend)?;
let mut rows = Vec::new();
for entry in table.iter().map_err(backend)? {
let (_, value) = entry.map_err(backend)?;
rows.push(decode_row(value.value())?);
}
Ok(rows)
}
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError> {
let txn = self.db.begin_write().map_err(backend)?;
let (arity, next_id) = {
let meta = txn.open_table(meta_def()).map_err(backend)?;
let entry = meta
.get(name)
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
decode_meta(entry.value())?
};
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
{
let mut rows = txn.open_table(rows_def(name)).map_err(backend)?;
let encoded = encode_row(&row);
rows.insert(next_id, &encoded[..]).map_err(backend)?;
}
{
let mut meta = txn.open_table(meta_def()).map_err(backend)?;
let new_meta = encode_meta(arity, next_id + 1);
meta.insert(name, new_meta.as_ref()).map_err(backend)?;
}
txn.commit().map_err(backend)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn i(x: i64) -> Value {
Value::Int(x)
}
fn s(x: &str) -> Value {
Value::Str(x.to_string())
}
fn open_temp() -> Result<RedbStorage, StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
// The file does not have to exist for redb::create.
let path = dir.path().join("test.redb");
let storage = RedbStorage::open(&path)?;
// Keep the tempdir alive by leaking it (test-only).
std::mem::forget(dir);
Ok(storage)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![s("hello"), i(7)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![vec![i(1), i(2)], vec![s("hello"), i(7)]]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn insert_wrong_arity_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.insert("edge", vec![i(1)]),
Err(StorageError::ArityMismatch {
expected: 2,
got: 1,
})
));
Ok(())
}
}

View File

@ -1,161 +0,0 @@
//! Sled adapter.
//!
//! Maps each relation onto a sled [`Tree`](sled::Tree) of the same name. A
//! reserved tree named `__meta` carries per-relation metadata (arity and the
//! next synthetic row ID).
use crate::value::Value;
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row, row_key};
use crate::{Storage, StorageError};
const META_TREE: &str = "__meta";
fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> StorageError {
StorageError::Backend(Box::new(err))
}
/// Sled-backed [`Storage`] implementation.
pub struct SledStorage {
db: sled::Db,
}
impl SledStorage {
/// Open or create a sled database at `path`.
///
/// # Errors
/// Returns [`StorageError::Backend`] if sled fails to open the path.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
let db = sled::open(path).map_err(backend)?;
Ok(Self { db })
}
fn meta_tree(&self) -> Result<sled::Tree, StorageError> {
self.db.open_tree(META_TREE).map_err(backend)
}
fn relation_tree(&self, name: &str) -> Result<sled::Tree, StorageError> {
self.db.open_tree(name).map_err(backend)
}
fn load_meta(&self, name: &str) -> Result<(u32, u64), StorageError> {
let meta = self.meta_tree()?;
let raw = meta
.get(name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
Ok(decode_meta(raw.as_ref())?)
}
fn store_meta(&self, name: &str, arity: u32, next_id: u64) -> Result<(), StorageError> {
let meta = self.meta_tree()?;
let encoded = encode_meta(arity, next_id);
meta.insert(name.as_bytes(), encoded.as_ref())
.map_err(backend)?;
Ok(())
}
}
impl Storage for SledStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_TREE {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
let meta = self.meta_tree()?;
if meta.contains_key(name.as_bytes()).map_err(backend)? {
return Err(StorageError::RelationExists(name.to_string()));
}
let arity_u32 = u32::try_from(arity)
.map_err(|_| StorageError::Validation(format!("arity {arity} exceeds u32 range")))?;
self.store_meta(name, arity_u32, 0)?;
// open_tree creates the tree if it doesn't exist
let _ = self.relation_tree(name)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let (arity, _) = self.load_meta(name)?;
Ok(arity as usize)
}
fn scan(&self, name: &str) -> Result<Vec<Vec<Value>>, StorageError> {
let _ = self.load_meta(name)?;
let tree = self.relation_tree(name)?;
let mut rows = Vec::new();
for entry in &tree {
let (_, value) = entry.map_err(backend)?;
rows.push(decode_row(value.as_ref())?);
}
Ok(rows)
}
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<(), StorageError> {
let (arity, next_id) = self.load_meta(name)?;
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
let tree = self.relation_tree(name)?;
tree.insert(row_key(next_id), encode_row(&row))
.map_err(backend)?;
self.store_meta(name, arity, next_id + 1)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn i(x: i64) -> Value {
Value::Int(x)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let mut storage = SledStorage::open(dir.path())?;
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![i(2), i(3)])?;
storage.insert("edge", vec![i(3), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(
rows,
vec![vec![i(1), i(2)], vec![i(2), i(3)], vec![i(3), i(3)],],
);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let mut storage = SledStorage::open(dir.path())?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn insert_wrong_arity_returns_err() -> Result<(), StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let mut storage = SledStorage::open(dir.path())?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.insert("edge", vec![i(1)]),
Err(StorageError::ArityMismatch {
expected: 2,
got: 1,
})
));
Ok(())
}
}

View File

@ -1,5 +1,5 @@
[package]
name = "query-storage"
name = "storage"
version = "0.1.0"
edition.workspace = true
license.workspace = true
@ -16,15 +16,27 @@ default = []
lmdb = ["dep:heed"]
redb = ["dep:redb"]
fjall = ["dep:fjall"]
sled = ["dep:sled"]
sqlite = ["dep:rusqlite"]
geomerge = ["dep:geomerge"]
[dependencies]
smallvec = "1"
heed = { version = "0.20", optional = true }
redb = { version = "2", optional = true }
fjall = { version = "2", optional = true }
sled = { version = "0.34", optional = true }
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
geomerge = { path = "../../external/geomerge/crates/geomerge", optional = true }
[dev-dependencies]
tempfile = "3"
criterion = "0.5"
serde_json = "1"
[[bench]]
name = "adapter_compare"
harness = false
[[bench]]
name = "geomerge"
harness = false
required-features = ["geomerge"]

112
crates/storage/README.md Normal file
View File

@ -0,0 +1,112 @@
## Storage Abstraction Layer
This crate is an implementation of a storage access layer.
It defines an interface for storing and retrieving data from a storage backend, in a generic way.
Higher-level crates such as `query-ops` should use this crate to access the storage.
This crates helps with decoupling the query execution logic from the underlying storage implementation.
### Public API
| Item | Kind | Description |
|--------------------------------------------------------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Storage` | trait | Backend-agnostic interface for storing and retrieving rows. Required methods: `create_relation`, `arity`, `scan_iter`, and `transaction`. The rest (`scan`, `scan_where`, `insert`, `delete`) have default implementations. |
| `Transaction` | trait | Atomic batch of inserts and deletes against a `Storage`. `insert` returns a pending `RowId`; `commit` consumes the boxed transaction and returns a `CommittedTx`; dropping without committing rolls back. |
| `CommittedTx` | struct | Result of a successful `Transaction::commit`. Resolves pending `RowId`s returned during the transaction to their post-commit form via `resolve`. Empty for KV adapters where pending equals real; populated for `geomerge`. |
| `StorageError` | enum | Error type returned by every fallible method. Variants: `RelationNotFound`, `RelationExists`, `ArityMismatch`, `Validation`, `Decode`, `Unsupported`, and `Backend`. |
| `CodecError` | enum | Wire-format failure reported as `StorageError::Decode`. Variants describe truncation, unknown tags, length overruns, and UTF-8 errors. |
| `RowStream<'a>` | type alias | `Box<dyn Iterator<Item = Result<(RowId, Vec<Value>), StorageError>> + 'a>`. The value yielded by `Storage::scan_iter` and `Storage::scan_where`. |
| `RowId` | struct | Opaque, backend-assigned row identifier. Bytes are inline up to 36 bytes (covers every encoding the workspace produces today) and spill to the heap otherwise. Construct with `RowId::new(bytes)` or `RowId::from(u64)`. |
| `Value` | enum | Single cell value. Variants: `Int(i64)`, `Str(String)`, and `Id(RowId)`. `Value::Id` is the foreign-key reference used by `geomerge` and any future referencing backend. |
| `Table` | struct | Positional input relation with fixed arity. Produced from a backend scan by `scan_as_table`. Consumed by `query-ops` operators. |
| `scan_as_table(&dyn Storage, &str) -> Result<Table, StorageError>` | function | Materialize a relation from a `Storage` backend into a `Table` for query-language operators. Row IDs are dropped; only cell values remain. |
| `MemoryStorage` | struct | In-process backend kept in `HashMap`. Always available; useful for tests and snapshot oracles. |
| `adapters::sqlite::SqliteStorage` | struct (feat) | `SQLite`-backed `Storage`, behind the `sqlite` feature. Uses `rusqlite` with bundled libsqlite3; supports a single connection with native write transactions. |
| `adapters::redb::RedbStorage` | struct (feat) | Single-file B-tree backed `Storage`, behind the `redb` feature. Wraps `redb::WriteTransaction` for native atomic commits. |
| `adapters::fjall::FjallStorage` | struct (feat) | LSM-tree backed `Storage`, behind the `fjall` feature. Each relation gets a partition; transactions buffer inserts and apply them on commit. |
| `adapters::lmdb::LmdbStorage` | struct (feat) | mmap'd B-tree backed `Storage`, behind the `lmdb` feature. Wraps `heed`'s `RwTxn` for native atomic commits. |
| `adapters::geomerge::GeomergeStorage` | struct (feat) | CRDT-backed `Storage` over the workspace's `geomerge` crate, behind the `geomerge` feature. Wraps `geomerge::Transaction` and resolves pending row IDs via `CommittedTx`. Deletion is not supported (append-only log). |
Data types and their relationships:
<div align="center">
<picture>
<img alt="Types" src="docs/diagrams/types.svg" height="70%" width="70%">
</picture>
</div>
### Example
The example below opens an in-memory backend, declares a relation, inserts two rows inside a single transaction, then reads the result.
```rust
use storage::value::Value;
use storage::{MemoryStorage, Storage, StorageError};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn main() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
let (a, b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1), i(2)])?;
let b = tx.insert("edge", vec![i(2), i(3)])?;
let committed = tx.commit()?;
// For KV backends pending IDs equal real IDs, so resolve is the identity.
(committed.resolve(&a), committed.resolve(&b))
};
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(a, vec![i(1), i(2)]), (b, vec![i(2), i(3)])]);
Ok(())
}
```
Note that we can always swap `MemoryStorage` for any other adapter (for example `adapters::sqlite::SqliteStorage::open(":memory:")?`) without changing
anything in the code.
How a backend is used (logically):
<div align="center">
<picture>
<img alt="Workflow" src="docs/diagrams/workflow.svg" height="90%" width="90%">
</picture>
</div>
### Run the Tests
```sh
cargo test -p storage --all-features
```
### Notes
- **Opaque row IDs.**
A `RowId` is a backend-assigned byte sequence; callers do not interpret the bytes.
KV adapters use big-endian `u64`; the `geomerge` adapter encodes a `(CommitHash, counter)` pair.
Hand a `RowId` back to the same backend to reference an existing row.
- **Pending row IDs.**
`Transaction::insert` may return a pending `RowId` that the backend cannot stabilize until commit; this is the case for `geomerge`, where the final
ID depends on the resulting `CommitHash`.
Resolve such IDs through the `CommittedTx` returned by `commit`.
For all KV backends the pending ID is already the real one and `CommittedTx::resolve` is the identity.
- **Streaming first.**
`scan_iter` is the primary scan operation; `scan` defaults to collecting it.
In-memory and LSM backends stream natively; B-tree and SQL backends materialize a `Vec` internally and yield from it to avoid self-referential
iterators.
- **Atomic transactions.**
For storage backends with write transactions support (LMDB, Redb, SQLite, and geomerge) we use their transaction API directly.
Adapters without native transaction support (MemoryStorage and Fjall) implement `Transaction` with an internal buffer of pending operations that are applied on `commit`.
Note that dropping a transaction without calling `commit` rolls back any pending operations.
- **Deletion support.**
Most adapters implement `delete`.
The `geomerge` adapter does not: its append-only commit log returns `StorageError::Unsupported("row deletion")`.
- **Geomerge is alpha.**
The upstream `geomerge` crate is prototype-status and its API can change without notice; treat breakage in `adapters::geomerge` as expected churn
rather than regression.
- **Feature gates.**
`MemoryStorage` is always available.
Every other adapter is feature-gated (`lmdb`, `redb`, `fjall`, `sqlite`, and `geomerge`) so callers only pay for what they need.

View File

@ -0,0 +1,263 @@
//! Compare the storage adapters on identical workloads.
//!
//! Three workloads:
//!
//! - `insert_n`: one transaction, N inserts, commit. Measures the per-row
//! write path plus tx commit overhead.
//! - `scan_full`: `storage.scan(name)`. Measures materialized scan cost.
//! - `scan_iter_drain`: drain `storage.scan_iter(name)` with `.count()`.
//! Measures the streaming-or-materialize-then-yield cost.
//!
//! Adapters: memory always; sqlite, redb, fjall, and lmdb gated on their
//! respective features. Geomerge is intentionally excluded: it requires a
//! pre-loaded theory and its insert semantics (foreign-key references, law
//! validation at commit) differ enough that side-by-side comparison would
//! mislead.
//!
//! Run with `cargo bench -p storage --all-features` for the full table.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use storage::value::Value;
use storage::{MemoryStorage, Storage};
#[cfg(feature = "fjall")]
use storage::adapters::fjall::FjallStorage;
#[cfg(feature = "lmdb")]
use storage::adapters::lmdb::LmdbStorage;
#[cfg(feature = "redb")]
use storage::adapters::redb::RedbStorage;
#[cfg(feature = "sqlite")]
use storage::adapters::sqlite::SqliteStorage;
const ROW_COUNTS: &[usize] = &[100, 1_000, 10_000];
fn make_row(i: usize) -> Vec<Value> {
vec![Value::Int(i as i64), Value::Int((i * 2) as i64)]
}
fn populate(storage: &mut dyn Storage, n: usize) {
storage.create_relation("rel", 2).unwrap();
let mut tx = storage.transaction().unwrap();
for i in 0..n {
tx.insert("rel", make_row(i)).unwrap();
}
tx.commit().unwrap();
}
fn run_insert_workload(storage: &mut dyn Storage, n: usize) {
storage.create_relation("rel", 2).unwrap();
let mut tx = storage.transaction().unwrap();
for i in 0..n {
tx.insert("rel", make_row(i)).unwrap();
}
tx.commit().unwrap();
}
fn bench_insert_n(c: &mut Criterion) {
let mut group = c.benchmark_group("insert_n");
for &n in ROW_COUNTS {
group.bench_with_input(BenchmarkId::new("memory", n), &n, |b, &n| {
b.iter_batched(
MemoryStorage::new,
|mut s| {
run_insert_workload(&mut s, n);
black_box(s);
},
BatchSize::SmallInput,
);
});
#[cfg(feature = "sqlite")]
group.bench_with_input(BenchmarkId::new("sqlite-mem", n), &n, |b, &n| {
b.iter_batched(
|| SqliteStorage::open(":memory:").unwrap(),
|mut s| {
run_insert_workload(&mut s, n);
black_box(s);
},
BatchSize::SmallInput,
);
});
#[cfg(feature = "redb")]
group.bench_with_input(BenchmarkId::new("redb", n), &n, |b, &n| {
b.iter_batched(
|| {
let dir = tempfile::tempdir().unwrap();
let storage = RedbStorage::open(dir.path().join("db")).unwrap();
(storage, dir)
},
|(mut s, dir)| {
run_insert_workload(&mut s, n);
black_box((s, dir));
},
BatchSize::SmallInput,
);
});
#[cfg(feature = "fjall")]
group.bench_with_input(BenchmarkId::new("fjall", n), &n, |b, &n| {
b.iter_batched(
|| {
let dir = tempfile::tempdir().unwrap();
let storage = FjallStorage::open(dir.path()).unwrap();
(storage, dir)
},
|(mut s, dir)| {
run_insert_workload(&mut s, n);
black_box((s, dir));
},
BatchSize::SmallInput,
);
});
#[cfg(feature = "lmdb")]
group.bench_with_input(BenchmarkId::new("lmdb", n), &n, |b, &n| {
b.iter_batched(
|| {
let dir = tempfile::tempdir().unwrap();
let storage = LmdbStorage::open(dir.path()).unwrap();
(storage, dir)
},
|(mut s, dir)| {
run_insert_workload(&mut s, n);
black_box((s, dir));
},
BatchSize::SmallInput,
);
});
}
group.finish();
}
fn bench_scan_full(c: &mut Criterion) {
let mut group = c.benchmark_group("scan_full");
for &n in ROW_COUNTS {
group.bench_with_input(BenchmarkId::new("memory", n), &n, |b, &n| {
let mut s = MemoryStorage::new();
populate(&mut s, n);
b.iter(|| {
let rows = s.scan("rel").unwrap();
black_box(rows);
});
});
#[cfg(feature = "sqlite")]
group.bench_with_input(BenchmarkId::new("sqlite-mem", n), &n, |b, &n| {
let mut s = SqliteStorage::open(":memory:").unwrap();
populate(&mut s, n);
b.iter(|| {
let rows = s.scan("rel").unwrap();
black_box(rows);
});
});
#[cfg(feature = "redb")]
group.bench_with_input(BenchmarkId::new("redb", n), &n, |b, &n| {
let dir = tempfile::tempdir().unwrap();
let mut s = RedbStorage::open(dir.path().join("db")).unwrap();
populate(&mut s, n);
b.iter(|| {
let rows = s.scan("rel").unwrap();
black_box(rows);
});
drop(dir);
});
#[cfg(feature = "fjall")]
group.bench_with_input(BenchmarkId::new("fjall", n), &n, |b, &n| {
let dir = tempfile::tempdir().unwrap();
let mut s = FjallStorage::open(dir.path()).unwrap();
populate(&mut s, n);
b.iter(|| {
let rows = s.scan("rel").unwrap();
black_box(rows);
});
drop(dir);
});
#[cfg(feature = "lmdb")]
group.bench_with_input(BenchmarkId::new("lmdb", n), &n, |b, &n| {
let dir = tempfile::tempdir().unwrap();
let mut s = LmdbStorage::open(dir.path()).unwrap();
populate(&mut s, n);
b.iter(|| {
let rows = s.scan("rel").unwrap();
black_box(rows);
});
drop(dir);
});
}
group.finish();
}
fn bench_scan_iter_drain(c: &mut Criterion) {
let mut group = c.benchmark_group("scan_iter_drain");
for &n in ROW_COUNTS {
group.bench_with_input(BenchmarkId::new("memory", n), &n, |b, &n| {
let mut s = MemoryStorage::new();
populate(&mut s, n);
b.iter(|| {
let count = s.scan_iter("rel").unwrap().filter_map(Result::ok).count();
black_box(count);
});
});
#[cfg(feature = "sqlite")]
group.bench_with_input(BenchmarkId::new("sqlite-mem", n), &n, |b, &n| {
let mut s = SqliteStorage::open(":memory:").unwrap();
populate(&mut s, n);
b.iter(|| {
let count = s.scan_iter("rel").unwrap().filter_map(Result::ok).count();
black_box(count);
});
});
#[cfg(feature = "redb")]
group.bench_with_input(BenchmarkId::new("redb", n), &n, |b, &n| {
let dir = tempfile::tempdir().unwrap();
let mut s = RedbStorage::open(dir.path().join("db")).unwrap();
populate(&mut s, n);
b.iter(|| {
let count = s.scan_iter("rel").unwrap().filter_map(Result::ok).count();
black_box(count);
});
drop(dir);
});
#[cfg(feature = "fjall")]
group.bench_with_input(BenchmarkId::new("fjall", n), &n, |b, &n| {
let dir = tempfile::tempdir().unwrap();
let mut s = FjallStorage::open(dir.path()).unwrap();
populate(&mut s, n);
b.iter(|| {
let count = s.scan_iter("rel").unwrap().filter_map(Result::ok).count();
black_box(count);
});
drop(dir);
});
#[cfg(feature = "lmdb")]
group.bench_with_input(BenchmarkId::new("lmdb", n), &n, |b, &n| {
let dir = tempfile::tempdir().unwrap();
let mut s = LmdbStorage::open(dir.path()).unwrap();
populate(&mut s, n);
b.iter(|| {
let count = s.scan_iter("rel").unwrap().filter_map(Result::ok).count();
black_box(count);
});
drop(dir);
});
}
group.finish();
}
criterion_group!(
benches,
bench_insert_n,
bench_scan_full,
bench_scan_iter_drain
);
criterion_main!(benches);

View File

@ -0,0 +1,118 @@
//! Geomerge-specific benchmarks.
//!
//! Geomerge has a different shape from the KV adapters: theory-defined schema,
//! foreign-key value type, law validation at commit, and pending row id
//! resolution via `CommittedTx`. The three workloads here answer questions
//! specific to that shape:
//!
//! - `theory_load`: deserialize the bundled `paths.json` and build the
//! `GeomergeStorage`. One-shot, not parameterized.
//! - `insert_commit_n`: in one transaction, insert prerequisite Graphs / G0
//! / G1 rows and then N vertices in a host graph, then commit. Commit cost
//! reflects geomerge's law validation over the new state.
//! - `resolve_pending_n`: after a commit that produced N pending RowIds,
//! resolve each via `CommittedTx::resolve`. Measures the cost of mapping
//! in-flight ids to their post-commit form.
//!
//! Requires the `geomerge` feature; the bench is gated via
//! `required-features` in `Cargo.toml`.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use geomerge::ir::FlatTheory;
use storage::adapters::geomerge::GeomergeStorage;
use storage::id::RowId;
use storage::value::Value;
use storage::{Storage, Transaction};
const PATHS_SCHEMA_JSON: &str =
include_str!("../../../external/geomerge/crates/geomerge/tests/data/paths.json");
const ROW_COUNTS: &[usize] = &[10, 100, 1_000];
fn load_theory() -> FlatTheory {
serde_json::from_str(PATHS_SCHEMA_JSON).unwrap()
}
fn fresh_storage() -> GeomergeStorage {
GeomergeStorage::from_theory(load_theory()).unwrap()
}
/// Insert the schema-required Graphs, G0, and G1 rows. Returns the RowId of
/// the "host" graph that subsequent vertex inserts should reference.
fn build_prereqs(tx: &mut dyn Transaction) -> RowId {
let designated = tx.insert("Graphs", vec![]).unwrap();
let host = tx.insert("Graphs", vec![]).unwrap();
tx.insert("G0", vec![Value::Id(designated.clone())])
.unwrap();
tx.insert("G1", vec![Value::Id(designated)]).unwrap();
host
}
fn insert_n_vertices(tx: &mut dyn Transaction, host: &RowId, n: usize) -> Vec<RowId> {
let mut pending = Vec::with_capacity(n);
for _ in 0..n {
pending.push(tx.insert("G.V", vec![Value::Id(host.clone())]).unwrap());
}
pending
}
fn bench_theory_load(c: &mut Criterion) {
c.bench_function("theory_load", |b| {
b.iter(|| {
let theory = load_theory();
let storage = GeomergeStorage::from_theory(theory).unwrap();
black_box(storage);
});
});
}
fn bench_insert_commit_n(c: &mut Criterion) {
let mut group = c.benchmark_group("insert_commit_n");
for &n in ROW_COUNTS {
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| {
b.iter_batched(
fresh_storage,
|mut storage| {
let mut tx = storage.transaction().unwrap();
let host = build_prereqs(&mut *tx);
let _ = insert_n_vertices(&mut *tx, &host, n);
tx.commit().unwrap();
black_box(storage);
},
BatchSize::SmallInput,
);
});
}
group.finish();
}
fn bench_resolve_pending_n(c: &mut Criterion) {
let mut group = c.benchmark_group("resolve_pending_n");
for &n in ROW_COUNTS {
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| {
// One-time setup outside the timed window: build a CommittedTx
// with N pending vertex IDs.
let mut storage = fresh_storage();
let mut tx = storage.transaction().unwrap();
let host = build_prereqs(&mut *tx);
let pending = insert_n_vertices(&mut *tx, &host, n);
let committed = tx.commit().unwrap();
b.iter(|| {
for p in &pending {
black_box(committed.resolve(p));
}
});
});
}
group.finish();
}
criterion_group!(
benches,
bench_theory_load,
bench_insert_commit_n,
bench_resolve_pending_n
);
criterion_main!(benches);

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# You need to have Graphviz installed to run this script
# On Debian-based OSes, you can install it using: sudo apt-get install graphviz
# Directory containing .dot files. Defaults to the script's own directory so the
# script works regardless of the caller's working directory.
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ASSET_DIR=${1:-"${SCRIPT_DIR}"}
# Make figures from .dot files
for f in "${ASSET_DIR}"/*.dot; do
dot -Tsvg "$f" -o "${f%.dot}.svg"
done

View File

@ -0,0 +1,118 @@
digraph StorageTypes {
fontname = "Helvetica,Arial,sans-serif"
layout = dot
rankdir = TB
ranksep = 0.7;
nodesep = 0.7;
splines = true;
bgcolor = "white"
node [
fontname = "Helvetica,Arial,sans-serif",
shape = box,
style = "filled,rounded",
color = "#555555",
fillcolor = "white",
penwidth = 1.5
]
edge [
fontname = "Helvetica,Arial,sans-serif",
color = "#333333",
fontsize = 9,
fontcolor = "#555555",
penwidth = 1.2
]
storage_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Storage</b> (trait)</td></tr>
<tr><td align="left" balign="left">create_relation(name, arity)</td></tr>
<tr><td align="left" balign="left">arity(name)</td></tr>
<tr><td align="left" balign="left">scan_iter(name) -&gt; RowStream</td></tr>
<tr><td align="left" balign="left">scan(name) -&gt; Vec&lt;(RowId, Vec&lt;Value&gt;)&gt;</td></tr>
<tr><td align="left" balign="left">scan_where(name, col, value)</td></tr>
<tr><td align="left" balign="left">transaction() -&gt; Box&lt;dyn Transaction&gt;</td></tr>
<tr><td align="left" balign="left">insert(name, row) -&gt; RowId</td></tr>
<tr><td align="left" balign="left">delete(name, id)</td></tr>
</table>>, fillcolor = "#E8F4FD", color = "#2196F3"]
transaction_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Transaction</b> (trait)</td></tr>
<tr><td align="left" balign="left">insert(name, row) -&gt; RowId</td></tr>
<tr><td align="left" balign="left">delete(name, id)</td></tr>
<tr><td align="left" balign="left">commit() -&gt; CommittedTx</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50"]
committed_tx_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>CommittedTx</b> (struct)</td></tr>
<tr><td align="left" balign="left">resolutions: HashMap&lt;RowId, RowId&gt;</td></tr>
<tr><td align="left" balign="left">resolve(pending) -&gt; RowId</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50"]
row_stream_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>RowStream&lt;'a&gt;</b> (type alias)</td></tr>
<tr><td align="left" balign="left">Box&lt;dyn Iterator&lt;Item =</td></tr>
<tr><td align="left" balign="left"> Result&lt;(RowId, Vec&lt;Value&gt;), StorageError&gt;</td></tr>
<tr><td align="left" balign="left">&gt; + 'a&gt;</td></tr>
</table>>, fillcolor = "#ECEFF1", color = "#607D8B"]
row_id_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>RowId</b> (struct)</td></tr>
<tr><td align="left" balign="left">SmallVec&lt;[u8; 36]&gt; (opaque)</td></tr>
<tr><td align="left" balign="left">new(bytes), as_bytes(), from(u64)</td></tr>
</table>>, fillcolor = "#FFF3E0", color = "#FF9800"]
value_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Value</b> (enum)</td></tr>
<tr><td align="left" balign="left">Int(i64)</td></tr>
<tr><td align="left" balign="left">Str(String)</td></tr>
<tr><td align="left" balign="left">Id(RowId)</td></tr>
</table>>, fillcolor = "#FFF3E0", color = "#FF9800"]
table_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Table</b> (struct)</td></tr>
<tr><td align="left" balign="left">arity: usize</td></tr>
<tr><td align="left" balign="left">rows: Vec&lt;Vec&lt;Value&gt;&gt;</td></tr>
</table>>, fillcolor = "#E8F4FD", color = "#2196F3"]
storage_error_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>StorageError</b> (enum)</td></tr>
<tr><td align="left" balign="left">RelationNotFound(String)</td></tr>
<tr><td align="left" balign="left">RelationExists(String)</td></tr>
<tr><td align="left" balign="left">ArityMismatch { expected, got }</td></tr>
<tr><td align="left" balign="left">Validation(String)</td></tr>
<tr><td align="left" balign="left">Decode(CodecError)</td></tr>
<tr><td align="left" balign="left">Unsupported(&amp;'static str)</td></tr>
<tr><td align="left" balign="left">Backend(Box&lt;dyn Error&gt;)</td></tr>
</table>>, fillcolor = "#FBE9E7", color = "#E64A19"]
codec_error_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>CodecError</b> (enum)</td></tr>
<tr><td align="left" balign="left">UnexpectedEof</td></tr>
<tr><td align="left" balign="left">UnknownTag(u8)</td></tr>
<tr><td align="left" balign="left">LengthOverrun { declared, available }</td></tr>
<tr><td align="left" balign="left">InvalidUtf8</td></tr>
</table>>, fillcolor = "#FBE9E7", color = "#E64A19"]
adapters_node [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Adapters</b> (impl Storage)</td></tr>
<tr><td align="left" balign="left">MemoryStorage</td></tr>
<tr><td align="left" balign="left">SqliteStorage (feat sqlite)</td></tr>
<tr><td align="left" balign="left">RedbStorage (feat redb)</td></tr>
<tr><td align="left" balign="left">FjallStorage (feat fjall)</td></tr>
<tr><td align="left" balign="left">LmdbStorage (feat lmdb)</td></tr>
<tr><td align="left" balign="left">GeomergeStorage (feat geomerge)</td></tr>
</table>>, fillcolor = "#F3E5F5", color = "#9C27B0"]
// composition and produces-edges: arrow X -> Y reads "X yields / contains / depends on Y"
adapters_node -> storage_node [label = "impl", style = "dashed"]
storage_node -> transaction_node [label = "transaction() yields"]
transaction_node -> committed_tx_node [label = "commit() yields"]
committed_tx_node -> row_id_node [label = "resolve() yields"]
storage_node -> row_stream_node [label = "scan_iter yields"]
row_stream_node -> row_id_node [label = "Item = (RowId, _)"]
row_stream_node -> value_node [label = "Item = (_, Vec<Value>)"]
transaction_node -> row_id_node [label = "insert() yields"]
value_node -> row_id_node [label = "Id(RowId)"]
table_node -> value_node [label = "Vec<Vec<Value>>"]
storage_error_node -> codec_error_node [label = "Decode(CodecError)"]
}

View File

@ -0,0 +1,374 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.2.1 (0)
-->
<!-- Title: StorageTypes Pages: 1 -->
<svg width="836pt" height="1114pt"
viewBox="0.00 0.00 835.88 1114.00" xmlns="http://www.w3.org/2000/svg">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1110)">
<title>StorageTypes</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-1110 831.88,-1110 831.88,4 -4,4"/>
<!-- storage_node -->
<g id="node1" class="node">
<title>storage_node</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M439.38,-802.5C439.38,-802.5 180.63,-802.5 180.63,-802.5 174.63,-802.5 168.63,-796.5 168.63,-790.5 168.63,-790.5 168.63,-545.5 168.63,-545.5 168.63,-539.5 174.63,-533.5 180.63,-533.5 180.63,-533.5 439.38,-533.5 439.38,-533.5 445.38,-533.5 451.38,-539.5 451.38,-545.5 451.38,-545.5 451.38,-790.5 451.38,-790.5 451.38,-796.5 445.38,-802.5 439.38,-802.5"/>
<text text-anchor="start" x="265.01" y="-782.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Storage
</text>
<text text-anchor="start" x="316.01" y="-782.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(trait)
</text>
<text text-anchor="start" x="180.63" y="-752.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
create_relation(name, arity)
</text>
<text text-anchor="start" x="180.63" y="-723.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity(name)
</text>
<text text-anchor="start" x="180.63" y="-694.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
scan_iter(name) &#45;&gt; RowStream
</text>
<text text-anchor="start" x="180.63" y="-665.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
scan(name) &#45;&gt; Vec&lt;(RowId, Vec&lt;Value&gt;)&gt;
</text>
<text text-anchor="start" x="180.63" y="-636.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
scan_where(name, col, value)
</text>
<text text-anchor="start" x="180.63" y="-607.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
transaction() &#45;&gt; Box&lt;dyn Transaction&gt;
</text>
<text text-anchor="start" x="180.63" y="-578.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
insert(name, row) &#45;&gt; RowId
</text>
<text text-anchor="start" x="180.63" y="-549.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
delete(name, id)
</text>
</g>
<!-- transaction_node -->
<g id="node2" class="node">
<title>transaction_node</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M245.51,-470C245.51,-470 80.51,-470 80.51,-470 74.51,-470 68.51,-464 68.51,-458 68.51,-458 68.51,-358 68.51,-358 68.51,-352 74.51,-346 80.51,-346 80.51,-346 245.51,-346 245.51,-346 251.51,-346 257.51,-352 257.51,-358 257.51,-358 257.51,-458 257.51,-458 257.51,-464 251.51,-470 245.51,-470"/>
<text text-anchor="start" x="105.63" y="-449.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Transaction
</text>
<text text-anchor="start" x="181.38" y="-449.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(trait)
</text>
<text text-anchor="start" x="80.51" y="-419.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
insert(name, row) &#45;&gt; RowId
</text>
<text text-anchor="start" x="80.51" y="-390.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
delete(name, id)
</text>
<text text-anchor="start" x="80.51" y="-361.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
commit() &#45;&gt; CommittedTx
</text>
</g>
<!-- storage_node&#45;&gt;transaction_node -->
<g id="edge2" class="edge">
<title>storage_node&#45;&gt;transaction_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M233.71,-533.09C223.34,-514.9 213.06,-496.84 203.67,-480.36"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="206.83,-478.85 198.84,-471.9 200.75,-482.32 206.83,-478.85"/>
<text text-anchor="middle" x="257.64" y="-499.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">transaction() yields
</text>
</g>
<!-- row_stream_node -->
<g id="node4" class="node">
<title>row_stream_node</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5"
d="M594.26,-470C594.26,-470 319.76,-470 319.76,-470 313.76,-470 307.76,-464 307.76,-458 307.76,-458 307.76,-358 307.76,-358 307.76,-352 313.76,-346 319.76,-346 319.76,-346 594.26,-346 594.26,-346 600.26,-346 606.26,-352 606.26,-358 606.26,-358 606.26,-458 606.26,-458 606.26,-464 600.26,-470 594.26,-470"/>
<text text-anchor="start" x="368.88" y="-449.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">RowStream&lt;&#39;a&gt;
</text>
<text text-anchor="start" x="471.63" y="-449.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(type alias)
</text>
<text text-anchor="start" x="319.76" y="-419.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Box&lt;dyn Iterator&lt;Item =
</text>
<text text-anchor="start" x="319.76" y="-390.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;Result&lt;(RowId, Vec&lt;Value&gt;), StorageError&gt;
</text>
<text text-anchor="start" x="319.76" y="-361.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&gt; + &#39;a&gt;
</text>
</g>
<!-- storage_node&#45;&gt;row_stream_node -->
<g id="edge5" class="edge">
<title>storage_node&#45;&gt;row_stream_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M386.3,-533.09C396.67,-514.9 406.96,-496.84 416.34,-480.36"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="419.26,-482.32 421.17,-471.9 413.18,-478.85 419.26,-482.32"/>
<text text-anchor="middle" x="437.14" y="-499.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">scan_iter yields
</text>
</g>
<!-- committed_tx_node -->
<g id="node3" class="node">
<title>committed_tx_node</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M344.88,-268C344.88,-268 113.13,-268 113.13,-268 107.13,-268 101.13,-262 101.13,-256 101.13,-256 101.13,-185 101.13,-185 101.13,-179 107.13,-173 113.13,-173 113.13,-173 344.88,-173 344.88,-173 350.88,-173 356.88,-179 356.88,-185 356.88,-185 356.88,-256 356.88,-256 356.88,-262 350.88,-268 344.88,-268"/>
<text text-anchor="start" x="160.38" y="-247.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">CommittedTx
</text>
<text text-anchor="start" x="248.13" y="-247.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(struct)
</text>
<text text-anchor="start" x="113.13" y="-217.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
resolutions: HashMap&lt;RowId, RowId&gt;
</text>
<text text-anchor="start" x="113.13" y="-188.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
resolve(pending) &#45;&gt; RowId
</text>
</g>
<!-- transaction_node&#45;&gt;committed_tx_node -->
<g id="edge3" class="edge">
<title>transaction_node&#45;&gt;committed_tx_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M184.8,-345.75C192.41,-324.37 200.91,-300.45 208.42,-279.36"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="211.69,-280.6 211.75,-270 205.1,-278.25 211.69,-280.6"/>
<text text-anchor="middle" x="228.73" y="-312.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">commit() yields
</text>
</g>
<!-- row_id_node -->
<g id="node5" class="node">
<title>row_id_node</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M501.63,-95C501.63,-95 298.38,-95 298.38,-95 292.38,-95 286.38,-89 286.38,-83 286.38,-83 286.38,-12 286.38,-12 286.38,-6 292.38,0 298.38,0 298.38,0 501.63,0 501.63,0 507.63,0 513.63,-6 513.63,-12 513.63,-12 513.63,-83 513.63,-83 513.63,-89 507.63,-95 501.63,-95"/>
<text text-anchor="start" x="354.63" y="-74.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">RowId
</text>
<text text-anchor="start" x="395.88" y="-74.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(struct)
</text>
<text text-anchor="start" x="298.38" y="-44.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
SmallVec&lt;[u8; 36]&gt; (opaque)
</text>
<text text-anchor="start" x="298.38" y="-15.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
new(bytes), as_bytes(), from(u64)
</text>
</g>
<!-- transaction_node&#45;&gt;row_id_node -->
<g id="edge8" class="edge">
<title>transaction_node&#45;&gt;row_id_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M69.82,-345.56C50.18,-327.75 32.13,-306.52 21.26,-282.5 -1.47,-232.29 -11.84,-202.57 21.26,-158.5 52.05,-117.48 176.52,-86.8 274.48,-68.4"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="275,-71.87 284.19,-66.61 273.72,-64.98 275,-71.87"/>
<text text-anchor="middle" x="48.63" y="-218.7" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">insert() yields
</text>
</g>
<!-- committed_tx_node&#45;&gt;row_id_node -->
<g id="edge4" class="edge">
<title>committed_tx_node&#45;&gt;row_id_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M275.73,-172.77C297.13,-151.37 322.65,-125.86 344.97,-103.53"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="347.16,-106.3 351.75,-96.75 342.21,-101.35 347.16,-106.3"/>
<text text-anchor="middle" x="355.87" y="-124.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">resolve() yields
</text>
</g>
<!-- row_stream_node&#45;&gt;row_id_node -->
<g id="edge6" class="edge">
<title>row_stream_node&#45;&gt;row_id_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M447.27,-345.78C436.64,-278.9 419.73,-172.56 409.28,-106.84"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="412.78,-106.58 407.76,-97.25 405.87,-107.68 412.78,-106.58"/>
<text text-anchor="middle" x="471.44" y="-218.7" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Item = (RowId, _)
</text>
</g>
<!-- value_node -->
<g id="node6" class="node">
<title>value_node</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M675.51,-282.5C675.51,-282.5 588.51,-282.5 588.51,-282.5 582.51,-282.5 576.51,-276.5 576.51,-270.5 576.51,-270.5 576.51,-170.5 576.51,-170.5 576.51,-164.5 582.51,-158.5 588.51,-158.5 588.51,-158.5 675.51,-158.5 675.51,-158.5 681.51,-158.5 687.51,-164.5 687.51,-170.5 687.51,-170.5 687.51,-270.5 687.51,-270.5 687.51,-276.5 681.51,-282.5 675.51,-282.5"/>
<text text-anchor="start" x="588.51" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Value
</text>
<text text-anchor="start" x="624.51" y="-262.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(enum)
</text>
<text text-anchor="start" x="588.51" y="-232.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Int(i64)
</text>
<text text-anchor="start" x="588.51" y="-203.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Str(String)
</text>
<text text-anchor="start" x="588.51" y="-174.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Id(RowId)
</text>
</g>
<!-- row_stream_node&#45;&gt;value_node -->
<g id="edge7" class="edge">
<title>row_stream_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M514.79,-345.75C531.89,-327.63 550.7,-307.69 568.15,-289.18"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="570.5,-291.79 574.82,-282.12 565.41,-286.99 570.5,-291.79"/>
<text text-anchor="middle" x="595.5" y="-312.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Item = (_, Vec&lt;Value&gt;)
</text>
</g>
<!-- value_node&#45;&gt;row_id_node -->
<g id="edge9" class="edge">
<title>value_node&#45;&gt;row_id_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M576.14,-178.32C545.13,-155.47 506.2,-126.78 472.83,-102.18"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="475.17,-99.55 465.04,-96.44 471.01,-105.19 475.17,-99.55"/>
<text text-anchor="middle" x="528.92" y="-124.95" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Id(RowId)
</text>
</g>
<!-- table_node -->
<g id="node7" class="node">
<title>table_node</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M815.88,-455.5C815.88,-455.5 668.13,-455.5 668.13,-455.5 662.13,-455.5 656.13,-449.5 656.13,-443.5 656.13,-443.5 656.13,-372.5 656.13,-372.5 656.13,-366.5 662.13,-360.5 668.13,-360.5 668.13,-360.5 815.88,-360.5 815.88,-360.5 821.88,-360.5 827.88,-366.5 827.88,-372.5 827.88,-372.5 827.88,-443.5 827.88,-443.5 827.88,-449.5 821.88,-455.5 815.88,-455.5"/>
<text text-anchor="start" x="700.01" y="-435.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Table
</text>
<text text-anchor="start" x="734.51" y="-435.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(struct)
</text>
<text text-anchor="start" x="668.13" y="-405.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity: usize
</text>
<text text-anchor="start" x="668.13" y="-376.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
rows: Vec&lt;Vec&lt;Value&gt;&gt;
</text>
</g>
<!-- table_node&#45;&gt;value_node -->
<g id="edge10" class="edge">
<title>table_node&#45;&gt;value_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M714.25,-360.19C702.11,-339.72 687.59,-315.24 674.17,-292.6"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="677.32,-291.06 669.21,-284.24 671.3,-294.63 677.32,-291.06"/>
<text text-anchor="middle" x="727.43" y="-312.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Vec&lt;Vec&lt;Value&gt;&gt;
</text>
</g>
<!-- storage_error_node -->
<g id="node8" class="node">
<title>storage_error_node</title>
<path fill="#fbe9e7" stroke="#e64a19" stroke-width="1.5"
d="M718.76,-1106C718.76,-1106 531.26,-1106 531.26,-1106 525.26,-1106 519.26,-1100 519.26,-1094 519.26,-1094 519.26,-878 519.26,-878 519.26,-872 525.26,-866 531.26,-866 531.26,-866 718.76,-866 718.76,-866 724.76,-866 730.76,-872 730.76,-878 730.76,-878 730.76,-1094 730.76,-1094 730.76,-1100 724.76,-1106 718.76,-1106"/>
<text text-anchor="start" x="557.51" y="-1085.7" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">StorageError
</text>
<text text-anchor="start" x="641.51" y="-1085.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(enum)
</text>
<text text-anchor="start" x="531.26" y="-1055.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
RelationNotFound(String)
</text>
<text text-anchor="start" x="531.26" y="-1026.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
RelationExists(String)
</text>
<text text-anchor="start" x="531.26" y="-997.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
ArityMismatch { expected, got }
</text>
<text text-anchor="start" x="531.26" y="-968.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Validation(String)
</text>
<text text-anchor="start" x="531.26" y="-939.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Decode(CodecError)
</text>
<text text-anchor="start" x="531.26" y="-910.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Unsupported(&amp;&#39;static str)
</text>
<text text-anchor="start" x="531.26" y="-881.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Backend(Box&lt;dyn Error&gt;)
</text>
</g>
<!-- codec_error_node -->
<g id="node9" class="node">
<title>codec_error_node</title>
<path fill="#fbe9e7" stroke="#e64a19" stroke-width="1.5"
d="M736.76,-744.5C736.76,-744.5 513.26,-744.5 513.26,-744.5 507.26,-744.5 501.26,-738.5 501.26,-732.5 501.26,-732.5 501.26,-603.5 501.26,-603.5 501.26,-597.5 507.26,-591.5 513.26,-591.5 513.26,-591.5 736.76,-591.5 736.76,-591.5 742.76,-591.5 748.76,-597.5 748.76,-603.5 748.76,-603.5 748.76,-732.5 748.76,-732.5 748.76,-738.5 742.76,-744.5 736.76,-744.5"/>
<text text-anchor="start" x="562.38" y="-724.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">CodecError
</text>
<text text-anchor="start" x="636.63" y="-724.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(enum)
</text>
<text text-anchor="start" x="513.26" y="-694.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
UnexpectedEof
</text>
<text text-anchor="start" x="513.26" y="-665.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
UnknownTag(u8)
</text>
<text text-anchor="start" x="513.26" y="-636.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
LengthOverrun { declared, available }
</text>
<text text-anchor="start" x="513.26" y="-607.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
InvalidUtf8
</text>
</g>
<!-- storage_error_node&#45;&gt;codec_error_node -->
<g id="edge11" class="edge">
<title>storage_error_node&#45;&gt;codec_error_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2"
d="M625.01,-865.53C625.01,-829.26 625.01,-790.11 625.01,-756.36"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="628.51,-756.48 625.01,-746.48 621.51,-756.48 628.51,-756.48"/>
<text text-anchor="middle" x="667.76" y="-832.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">Decode(CodecError)
</text>
</g>
<!-- adapters_node -->
<g id="node10" class="node">
<title>adapters_node</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M418.38,-1091.5C418.38,-1091.5 201.63,-1091.5 201.63,-1091.5 195.63,-1091.5 189.63,-1085.5 189.63,-1079.5 189.63,-1079.5 189.63,-892.5 189.63,-892.5 189.63,-886.5 195.63,-880.5 201.63,-880.5 201.63,-880.5 418.38,-880.5 418.38,-880.5 424.38,-880.5 430.38,-886.5 430.38,-892.5 430.38,-892.5 430.38,-1079.5 430.38,-1079.5 430.38,-1085.5 424.38,-1091.5 418.38,-1091.5"/>
<text text-anchor="start" x="234.26" y="-1071.2" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Adapters
</text>
<text text-anchor="start" x="292.76" y="-1071.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#160;(impl Storage)
</text>
<text text-anchor="start" x="201.63" y="-1041.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
MemoryStorage
</text>
<text text-anchor="start" x="201.63" y="-1012.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
SqliteStorage &#160;(feat sqlite)
</text>
<text text-anchor="start" x="201.63" y="-983.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
RedbStorage &#160;(feat redb)
</text>
<text text-anchor="start" x="201.63" y="-954.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
FjallStorage &#160;(feat fjall)
</text>
<text text-anchor="start" x="201.63" y="-925.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
LmdbStorage &#160;(feat lmdb)
</text>
<text text-anchor="start" x="201.63" y="-896.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
GeomergeStorage &#160;(feat geomerge)
</text>
</g>
<!-- adapters_node&#45;&gt;storage_node -->
<g id="edge1" class="edge">
<title>adapters_node&#45;&gt;storage_node</title>
<path fill="none" stroke="#333333" stroke-width="1.2" stroke-dasharray="5,2"
d="M310.01,-880.22C310.01,-859.1 310.01,-836.56 310.01,-814.34"/>
<polygon fill="#333333" stroke="#333333" stroke-width="1.2"
points="313.51,-814.58 310.01,-804.58 306.51,-814.58 313.51,-814.58"/>
<text text-anchor="middle" x="318.63" y="-832.45" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">impl
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,135 @@
digraph StorageWorkflow {
fontname = "Helvetica,Arial,sans-serif"
layout = dot
rankdir = LR
ranksep = 0.9;
nodesep = 0.7;
splines = true;
compound = true;
bgcolor = "white"
node [
fontname = "Helvetica,Arial,sans-serif",
shape = box,
style = "filled,rounded",
color = "#555555",
fillcolor = "white",
penwidth = 1.5
]
edge [
fontname = "Helvetica,Arial,sans-serif",
color = "#333333",
fontsize = 9,
fontcolor = "#555555",
labeldistance = 2.0,
penwidth = 1.2
]
subgraph cluster_inputs {
label = "Inputs"
style = "dashed"
color = "#888888"
fontcolor = "#555555"
margin = 18
schema [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Schema</b></td></tr>
<tr><td align="left" balign="left">• relation name</td></tr>
<tr><td align="left" balign="left">• arity (column count)</td></tr>
</table>>, fillcolor = "#E8F4FD", color = "#2196F3"]
row_data [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Row Data</b></td></tr>
<tr><td align="left" balign="left">• Vec&lt;Value&gt;</td></tr>
<tr><td align="left" balign="left">• Int / Str / Id(RowId)</td></tr>
</table>>, fillcolor = "#E8F4FD", color = "#2196F3"]
}
subgraph cluster_setup {
label = "Setup (open backend, declare relations)"
style = "dashed"
color = "#9C27B0"
fontcolor = "#7B1FA2"
margin = 14
open_backend [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Open Backend</b></td></tr>
<tr><td align="left" balign="left">MemoryStorage::new() /</td></tr>
<tr><td align="left" balign="left">SqliteStorage::open(path) /</td></tr>
<tr><td align="left" balign="left">FjallStorage::open(path) / ...</td></tr>
</table>>, fillcolor = "#F3E5F5", color = "#9C27B0"]
create_relation [label = "storage.create_relation(name, arity)", fillcolor = "#F3E5F5", color = "#9C27B0"]
}
subgraph cluster_write {
label = "Write (atomic batch via Transaction)"
style = "dashed"
color = "#4CAF50"
fontcolor = "#388E3C"
margin = 14
begin_tx [label = "storage.transaction()\n-&gt; Box&lt;dyn Transaction&gt;", fillcolor = "#E8F5E9", color = "#4CAF50"]
tx_ops [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>tx.insert / tx.delete</b></td></tr>
<tr><td align="left" balign="left">• insert yields pending RowId</td></tr>
<tr><td align="left" balign="left">• pending RowIds reused as FKs</td></tr>
<tr><td align="left" balign="left">• delete by RowId</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50", shape = box]
commit [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>tx.commit()</b></td></tr>
<tr><td align="left" balign="left">• native commit (LMDB, redb, SQLite, geomerge)</td></tr>
<tr><td align="left" balign="left">• buffered apply (memory, fjall)</td></tr>
<tr><td align="left" balign="left">• law validation (geomerge)</td></tr>
<tr><td align="left" balign="left">• yields CommittedTx</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50", shape = box]
resolve_ids [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>CommittedTx::resolve</b></td></tr>
<tr><td align="left" balign="left">• KV: pending == real</td></tr>
<tr><td align="left" balign="left">• geomerge: pending counter → (commit, counter)</td></tr>
</table>>, fillcolor = "#E8F5E9", color = "#4CAF50", shape = box]
}
subgraph cluster_read {
label = "Read"
style = "dashed"
color = "#FF9800"
fontcolor = "#F57C00"
margin = 14
scan_iter [label = "storage.scan_iter(name)\n-&gt; RowStream", fillcolor = "#FFF3E0", color = "#FF9800"]
scan_where [label = "storage.scan_where(name, col, value)\n-&gt; RowStream (filtered)", fillcolor = "#FFF3E0", color = "#FF9800"]
scan_full [label = "storage.scan(name)\n-&gt; Vec&lt;(RowId, Vec&lt;Value&gt;)&gt;", fillcolor = "#FFF3E0", color = "#FF9800"]
}
subgraph cluster_output {
label = "Output"
style = "dashed"
color = "#888888"
fontcolor = "#555555"
margin = 18
rows_out [label = <<table border="0" cellborder="0" cellspacing="0" cellpadding="4">
<tr><td align="center"><b>Rows</b></td></tr>
<tr><td align="left" balign="left">• (RowId, Vec&lt;Value&gt;)</td></tr>
<tr><td align="left" balign="left">• consumed by query-ops</td></tr>
<tr><td align="left" balign="left"> via scan_as_table</td></tr>
</table>>, fillcolor = "#ECEFF1", color = "#607D8B"]
}
// Setup
schema -> create_relation [color = "#2196F3"]
open_backend -> create_relation [color = "#9C27B0"]
// Write path
create_relation -> begin_tx [color = "#4CAF50"]
begin_tx -> tx_ops [color = "#4CAF50"]
row_data -> tx_ops [style = "dashed", color = "#2196F3"]
tx_ops -> commit [color = "#4CAF50"]
commit -> resolve_ids [label = "CommittedTx", color = "#4CAF50"]
// Read path
create_relation -> scan_iter [style = "dashed", color = "#9C27B0"]
create_relation -> scan_where [style = "dashed", color = "#9C27B0"]
create_relation -> scan_full [style = "dashed", color = "#9C27B0"]
commit -> scan_iter [style = "dashed", label = "after commit", color = "#4CAF50"]
// Output
scan_iter -> rows_out [color = "#FF9800"]
scan_where -> rows_out [color = "#FF9800"]
scan_full -> rows_out [color = "#FF9800"]
resolve_ids -> rows_out [style = "dashed", label = "real RowIds", color = "#4CAF50"]
}

View File

@ -0,0 +1,359 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.2.1 (0)
-->
<!-- Title: StorageWorkflow Pages: 1 -->
<svg width="2196pt" height="573pt"
viewBox="0.00 0.00 2195.75 573.00" xmlns="http://www.w3.org/2000/svg">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 569)">
<title>StorageWorkflow</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-569 2191.75,-569 2191.75,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_inputs</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2"
points="23.5,-254 23.5,-557 211.75,-557 211.75,-254 23.5,-254"/>
<text text-anchor="middle" x="117.62" y="-539.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#555555">Inputs
</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_setup</title>
<polygon fill="white" stroke="#9c27b0" stroke-dasharray="5,2"
points="8,-65 8,-246 524.75,-246 524.75,-65 8,-65"/>
<text text-anchor="middle" x="266.38" y="-228.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#7b1fa2">Setup &#160;(open backend, declare relations)
</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_write</title>
<polygon fill="white" stroke="#4caf50" stroke-dasharray="5,2"
points="563.75,-323 563.75,-532 1882.5,-532 1882.5,-323 563.75,-323"/>
<text text-anchor="middle" x="1223.12" y="-514.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#388e3c">Write &#160;(atomic batch via Transaction)
</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_read</title>
<polygon fill="white" stroke="#ff9800" stroke-dasharray="5,2"
points="1568.25,-8 1568.25,-315 1841.75,-315 1841.75,-8 1568.25,-8"/>
<text text-anchor="middle" x="1705" y="-297.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#f57c00">Read
</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_output</title>
<polygon fill="white" stroke="#888888" stroke-dasharray="5,2"
points="1965.25,-117 1965.25,-306 2179.75,-306 2179.75,-117 1965.25,-117"/>
<text text-anchor="middle" x="2072.5" y="-288.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00"
fill="#555555">Output
</text>
</g>
<!-- schema -->
<g id="node1" class="node">
<title>schema</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M181.75,-366.12C181.75,-366.12 53.5,-366.12 53.5,-366.12 47.5,-366.12 41.5,-360.12 41.5,-354.12 41.5,-354.12 41.5,-283.88 41.5,-283.88 41.5,-277.88 47.5,-271.88 53.5,-271.88 53.5,-271.88 181.75,-271.88 181.75,-271.88 187.75,-271.88 193.75,-277.88 193.75,-283.88 193.75,-283.88 193.75,-354.12 193.75,-354.12 193.75,-360.12 187.75,-366.12 181.75,-366.12"/>
<text text-anchor="start" x="91.38" y="-345.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Schema
</text>
<text text-anchor="start" x="53.5" y="-316.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
relation name
</text>
<text text-anchor="start" x="53.5" y="-287.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
arity (column count)
</text>
</g>
<!-- create_relation -->
<g id="node4" class="node">
<title>create_relation</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M498.75,-184C498.75,-184 292.25,-184 292.25,-184 286.25,-184 280.25,-178 280.25,-172 280.25,-172 280.25,-160 280.25,-160 280.25,-154 286.25,-148 292.25,-148 292.25,-148 498.75,-148 498.75,-148 504.75,-148 510.75,-154 510.75,-160 510.75,-160 510.75,-172 510.75,-172 510.75,-178 504.75,-184 498.75,-184"/>
<text text-anchor="middle" x="395.5" y="-163.2" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
storage.create_relation(name, arity)
</text>
</g>
<!-- schema&#45;&gt;create_relation -->
<g id="edge1" class="edge">
<title>schema&#45;&gt;create_relation</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2"
d="M194.01,-277.19C244.45,-249.22 309.06,-213.38 351.1,-190.07"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="352.64,-193.22 359.69,-185.31 349.24,-187.1 352.64,-193.22"/>
</g>
<!-- row_data -->
<g id="node2" class="node">
<title>row_data</title>
<path fill="#e8f4fd" stroke="#2196f3" stroke-width="1.5"
d="M178.75,-510.12C178.75,-510.12 56.5,-510.12 56.5,-510.12 50.5,-510.12 44.5,-504.12 44.5,-498.12 44.5,-498.12 44.5,-427.88 44.5,-427.88 44.5,-421.88 50.5,-415.88 56.5,-415.88 56.5,-415.88 178.75,-415.88 178.75,-415.88 184.75,-415.88 190.75,-421.88 190.75,-427.88 190.75,-427.88 190.75,-498.12 190.75,-498.12 190.75,-504.12 184.75,-510.12 178.75,-510.12"/>
<text text-anchor="start" x="86.5" y="-489.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Row Data
</text>
<text text-anchor="start" x="56.5" y="-460.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Vec&lt;Value&gt;
</text>
<text text-anchor="start" x="56.5" y="-431.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
Int / Str / Id(RowId)
</text>
</g>
<!-- tx_ops -->
<g id="node6" class="node">
<title>tx_ops</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1020.75,-474.62C1020.75,-474.62 824.25,-474.62 824.25,-474.62 818.25,-474.62 812.25,-468.62 812.25,-462.62 812.25,-462.62 812.25,-363.38 812.25,-363.38 812.25,-357.38 818.25,-351.38 824.25,-351.38 824.25,-351.38 1020.75,-351.38 1020.75,-351.38 1026.75,-351.38 1032.75,-357.38 1032.75,-363.38 1032.75,-363.38 1032.75,-462.62 1032.75,-462.62 1032.75,-468.62 1026.75,-474.62 1020.75,-474.62"/>
<text text-anchor="start" x="862.88" y="-454.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">tx.insert / tx.delete
</text>
<text text-anchor="start" x="824.25" y="-425.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• insert yields pending RowId
</text>
<text text-anchor="start" x="824.25" y="-396.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• pending RowIds reused as FKs
</text>
<text text-anchor="start" x="824.25" y="-367.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• delete by RowId
</text>
</g>
<!-- row_data&#45;&gt;tx_ops -->
<g id="edge5" class="edge">
<title>row_data&#45;&gt;tx_ops</title>
<path fill="none" stroke="#2196f3" stroke-width="1.2" stroke-dasharray="5,2"
d="M190.94,-458.5C328.87,-449.91 630.86,-431.1 800.21,-420.55"/>
<polygon fill="#2196f3" stroke="#2196f3" stroke-width="1.2"
points="800.34,-424.05 810.11,-419.94 799.91,-417.07 800.34,-424.05"/>
</g>
<!-- open_backend -->
<g id="node3" class="node">
<title>open_backend</title>
<path fill="#f3e5f5" stroke="#9c27b0" stroke-width="1.5"
d="M201.25,-202.62C201.25,-202.62 34,-202.62 34,-202.62 28,-202.62 22,-196.62 22,-190.62 22,-190.62 22,-91.38 22,-91.38 22,-85.38 28,-79.38 34,-79.38 34,-79.38 201.25,-79.38 201.25,-79.38 207.25,-79.38 213.25,-85.38 213.25,-91.38 213.25,-91.38 213.25,-190.62 213.25,-190.62 213.25,-196.62 207.25,-202.62 201.25,-202.62"/>
<text text-anchor="start" x="70" y="-182.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Open Backend
</text>
<text text-anchor="start" x="34" y="-153.07" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
MemoryStorage::new() /
</text>
<text text-anchor="start" x="34" y="-124.08" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
SqliteStorage::open(path) /
</text>
<text text-anchor="start" x="34" y="-95.08" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
FjallStorage::open(path) / ...
</text>
</g>
<!-- open_backend&#45;&gt;create_relation -->
<g id="edge2" class="edge">
<title>open_backend&#45;&gt;create_relation</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2"
d="M213.57,-149.6C231.17,-151.2 249.8,-152.89 268.16,-154.55"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="267.78,-158.03 278.05,-155.45 268.41,-151.06 267.78,-158.03"/>
</g>
<!-- begin_tx -->
<g id="node5" class="node">
<title>begin_tx</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M733.25,-387C733.25,-387 589.75,-387 589.75,-387 583.75,-387 577.75,-381 577.75,-375 577.75,-375 577.75,-349 577.75,-349 577.75,-343 583.75,-337 589.75,-337 589.75,-337 733.25,-337 733.25,-337 739.25,-337 745.25,-343 745.25,-349 745.25,-349 745.25,-375 745.25,-375 745.25,-381 739.25,-387 733.25,-387"/>
<text text-anchor="middle" x="661.5" y="-369.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
storage.transaction()
</text>
<text text-anchor="middle" x="661.5" y="-348.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#45;&gt; Box&lt;dyn Transaction&gt;
</text>
</g>
<!-- create_relation&#45;&gt;begin_tx -->
<g id="edge3" class="edge">
<title>create_relation&#45;&gt;begin_tx</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M416.8,-184.23C444.61,-208.87 496.48,-253.65 543.75,-288 564.01,-302.72 587.16,-317.74 607.47,-330.37"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="605.6,-333.32 615.94,-335.59 609.27,-327.37 605.6,-333.32"/>
</g>
<!-- scan_iter -->
<g id="node9" class="node">
<title>scan_iter</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M1774.5,-272C1774.5,-272 1635.5,-272 1635.5,-272 1629.5,-272 1623.5,-266 1623.5,-260 1623.5,-260 1623.5,-234 1623.5,-234 1623.5,-228 1629.5,-222 1635.5,-222 1635.5,-222 1774.5,-222 1774.5,-222 1780.5,-222 1786.5,-228 1786.5,-234 1786.5,-234 1786.5,-260 1786.5,-260 1786.5,-266 1780.5,-272 1774.5,-272"/>
<text text-anchor="middle" x="1705" y="-254.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
storage.scan_iter(name)
</text>
<text text-anchor="middle" x="1705" y="-233.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#45;&gt; RowStream
</text>
</g>
<!-- create_relation&#45;&gt;scan_iter -->
<g id="edge8" class="edge">
<title>create_relation&#45;&gt;scan_iter</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" stroke-dasharray="5,2"
d="M430.83,-184.43C480.17,-209.16 574.65,-250 660.5,-250 660.5,-250 660.5,-250 1261.62,-250 1382.54,-250 1522.03,-248.87 1611.53,-247.99"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="1611.5,-251.49 1621.46,-247.89 1611.43,-244.49 1611.5,-251.49"/>
</g>
<!-- scan_where -->
<g id="node10" class="node">
<title>scan_where</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M1815.75,-72C1815.75,-72 1594.25,-72 1594.25,-72 1588.25,-72 1582.25,-66 1582.25,-60 1582.25,-60 1582.25,-34 1582.25,-34 1582.25,-28 1588.25,-22 1594.25,-22 1594.25,-22 1815.75,-22 1815.75,-22 1821.75,-22 1827.75,-28 1827.75,-34 1827.75,-34 1827.75,-60 1827.75,-60 1827.75,-66 1821.75,-72 1815.75,-72"/>
<text text-anchor="middle" x="1705" y="-54.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
storage.scan_where(name, col, value)
</text>
<text text-anchor="middle" x="1705" y="-33.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#45;&gt; RowStream &#160;(filtered)
</text>
</g>
<!-- create_relation&#45;&gt;scan_where -->
<g id="edge9" class="edge">
<title>create_relation&#45;&gt;scan_where</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" stroke-dasharray="5,2"
d="M419.28,-147.51C463.15,-113.85 563.58,-46 660.5,-46 660.5,-46 660.5,-46 1261.62,-46 1365.26,-46 1482.56,-46.28 1570.31,-46.54"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="1570.12,-50.04 1580.13,-46.57 1570.15,-43.04 1570.12,-50.04"/>
</g>
<!-- scan_full -->
<g id="node11" class="node">
<title>scan_full</title>
<path fill="#fff3e0" stroke="#ff9800" stroke-width="1.5"
d="M1792.5,-172C1792.5,-172 1617.5,-172 1617.5,-172 1611.5,-172 1605.5,-166 1605.5,-160 1605.5,-160 1605.5,-134 1605.5,-134 1605.5,-128 1611.5,-122 1617.5,-122 1617.5,-122 1792.5,-122 1792.5,-122 1798.5,-122 1804.5,-128 1804.5,-134 1804.5,-134 1804.5,-160 1804.5,-160 1804.5,-166 1798.5,-172 1792.5,-172"/>
<text text-anchor="middle" x="1705" y="-154.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
storage.scan(name)
</text>
<text text-anchor="middle" x="1705" y="-133.7" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
&#45;&gt; Vec&lt;(RowId, Vec&lt;Value&gt;)&gt;
</text>
</g>
<!-- create_relation&#45;&gt;scan_full -->
<g id="edge10" class="edge">
<title>create_relation&#45;&gt;scan_full</title>
<path fill="none" stroke="#9c27b0" stroke-width="1.2" stroke-dasharray="5,2"
d="M511.06,-154.78C557.41,-151.13 611.46,-148 660.5,-148 660.5,-148 660.5,-148 1261.62,-148 1374.63,-148 1503.88,-147.67 1593.36,-147.39"/>
<polygon fill="#9c27b0" stroke="#9c27b0" stroke-width="1.2"
points="1593.37,-150.89 1603.35,-147.36 1593.34,-143.89 1593.37,-150.89"/>
</g>
<!-- begin_tx&#45;&gt;tx_ops -->
<g id="edge4" class="edge">
<title>begin_tx&#45;&gt;tx_ops</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M745.52,-378.35C763.04,-381.8 781.87,-385.51 800.47,-389.17"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="799.73,-392.59 810.22,-391.09 801.09,-385.72 799.73,-392.59"/>
</g>
<!-- commit -->
<g id="node7" class="node">
<title>commit</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1409.5,-489.12C1409.5,-489.12 1111.75,-489.12 1111.75,-489.12 1105.75,-489.12 1099.75,-483.12 1099.75,-477.12 1099.75,-477.12 1099.75,-348.88 1099.75,-348.88 1099.75,-342.88 1105.75,-336.88 1111.75,-336.88 1111.75,-336.88 1409.5,-336.88 1409.5,-336.88 1415.5,-336.88 1421.5,-342.88 1421.5,-348.88 1421.5,-348.88 1421.5,-477.12 1421.5,-477.12 1421.5,-483.12 1415.5,-489.12 1409.5,-489.12"/>
<text text-anchor="start" x="1223.5" y="-468.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">tx.commit()
</text>
<text text-anchor="start" x="1111.75" y="-439.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• native commit (LMDB, redb, SQLite, geomerge)
</text>
<text text-anchor="start" x="1111.75" y="-410.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• buffered apply (memory, fjall)
</text>
<text text-anchor="start" x="1111.75" y="-381.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• law validation (geomerge)
</text>
<text text-anchor="start" x="1111.75" y="-352.57" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• yields CommittedTx
</text>
</g>
<!-- tx_ops&#45;&gt;commit -->
<g id="edge6" class="edge">
<title>tx_ops&#45;&gt;commit</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M1033.09,-413C1050.7,-413 1069.29,-413 1087.94,-413"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1087.74,-416.5 1097.74,-413 1087.74,-409.5 1087.74,-416.5"/>
</g>
<!-- resolve_ids -->
<g id="node8" class="node">
<title>resolve_ids</title>
<path fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5"
d="M1856.5,-438.12C1856.5,-438.12 1553.5,-438.12 1553.5,-438.12 1547.5,-438.12 1541.5,-432.12 1541.5,-426.12 1541.5,-426.12 1541.5,-355.88 1541.5,-355.88 1541.5,-349.88 1547.5,-343.88 1553.5,-343.88 1553.5,-343.88 1856.5,-343.88 1856.5,-343.88 1862.5,-343.88 1868.5,-349.88 1868.5,-355.88 1868.5,-355.88 1868.5,-426.12 1868.5,-426.12 1868.5,-432.12 1862.5,-438.12 1856.5,-438.12"/>
<text text-anchor="start" x="1633" y="-417.82" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">CommittedTx::resolve
</text>
<text text-anchor="start" x="1553.5" y="-388.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• KV: pending == real
</text>
<text text-anchor="start" x="1553.5" y="-359.57" font-family="Helvetica,Arial,sans-serif" font-size="14.00">
• geomerge: pending counter → (commit, counter)
</text>
</g>
<!-- commit&#45;&gt;resolve_ids -->
<g id="edge7" class="edge">
<title>commit&#45;&gt;resolve_ids</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2"
d="M1421.96,-405.03C1456.99,-403.28 1494.23,-401.43 1529.77,-399.66"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1529.61,-403.18 1539.43,-399.18 1529.27,-396.19 1529.61,-403.18"/>
<text text-anchor="middle" x="1481.5" y="-408.27" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">CommittedTx
</text>
</g>
<!-- commit&#45;&gt;scan_iter -->
<g id="edge11" class="edge">
<title>commit&#45;&gt;scan_iter</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" stroke-dasharray="5,2"
d="M1421.96,-352.84C1491.15,-326.87 1568.97,-297.67 1625.3,-276.53"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1626.44,-279.84 1634.58,-273.05 1623.98,-273.29 1626.44,-279.84"/>
<text text-anchor="middle" x="1481.5" y="-344.89" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">after commit
</text>
</g>
<!-- rows_out -->
<g id="node12" class="node">
<title>rows_out</title>
<path fill="#eceff1" stroke="#607d8b" stroke-width="1.5"
d="M2149.75,-258.62C2149.75,-258.62 1995.25,-258.62 1995.25,-258.62 1989.25,-258.62 1983.25,-252.62 1983.25,-246.62 1983.25,-246.62 1983.25,-147.38 1983.25,-147.38 1983.25,-141.38 1989.25,-135.38 1995.25,-135.38 1995.25,-135.38 2149.75,-135.38 2149.75,-135.38 2155.75,-135.38 2161.75,-141.38 2161.75,-147.38 2161.75,-147.38 2161.75,-246.62 2161.75,-246.62 2161.75,-252.62 2155.75,-258.62 2149.75,-258.62"/>
<text text-anchor="start" x="2054.5" y="-238.32" font-family="Helvetica,Arial,sans-serif" font-weight="bold"
font-size="14.00">Rows
</text>
<text text-anchor="start" x="1995.25" y="-209.07" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• (RowId, Vec&lt;Value&gt;)
</text>
<text text-anchor="start" x="1995.25" y="-180.07" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">• consumed by query&#45;ops
</text>
<text text-anchor="start" x="1995.25" y="-151.07" font-family="Helvetica,Arial,sans-serif"
font-size="14.00">&#160;via scan_as_table
</text>
</g>
<!-- resolve_ids&#45;&gt;rows_out -->
<g id="edge15" class="edge">
<title>resolve_ids&#45;&gt;rows_out</title>
<path fill="none" stroke="#4caf50" stroke-width="1.2" stroke-dasharray="5,2"
d="M1847.09,-343.54C1859.24,-338.34 1871.2,-332.82 1882.5,-327 1916.36,-309.58 1951.29,-287.04 1981.77,-265.63"/>
<polygon fill="#4caf50" stroke="#4caf50" stroke-width="1.2"
points="1983.46,-268.72 1989.6,-260.08 1979.41,-263.01 1983.46,-268.72"/>
<text text-anchor="middle" x="1925.88" y="-320.11" font-family="Helvetica,Arial,sans-serif" font-size="9.00"
fill="#555555">real RowIds
</text>
</g>
<!-- scan_iter&#45;&gt;rows_out -->
<g id="edge12" class="edge">
<title>scan_iter&#45;&gt;rows_out</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M1786.66,-235.97C1841.16,-228.51 1913.48,-218.62 1971.62,-210.66"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="1971.81,-214.17 1981.25,-209.35 1970.86,-207.23 1971.81,-214.17"/>
</g>
<!-- scan_where&#45;&gt;rows_out -->
<g id="edge13" class="edge">
<title>scan_where&#45;&gt;rows_out</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M1809.89,-72.49C1834.05,-79.5 1859.44,-87.75 1882.5,-97 1912.57,-109.06 1944.11,-124.52 1972.62,-139.66"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="1970.91,-142.71 1981.37,-144.36 1974.22,-136.55 1970.91,-142.71"/>
</g>
<!-- scan_full&#45;&gt;rows_out -->
<g id="edge14" class="edge">
<title>scan_full&#45;&gt;rows_out</title>
<path fill="none" stroke="#ff9800" stroke-width="1.2"
d="M1804.91,-160.53C1856.5,-167.59 1919.48,-176.2 1971.27,-183.29"/>
<polygon fill="#ff9800" stroke="#ff9800" stroke-width="1.2"
points="1970.63,-186.73 1981.02,-184.62 1971.58,-179.8 1970.63,-186.73"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,22 @@
//! Backend adapters for the [`Storage`](crate::Storage) trait.
//!
//! Each module here implements [`Storage`](crate::Storage) over a different
//! engine. [`memory`] is always available; the rest are gated behind Cargo
//! features so users only pay for what they need.
pub mod memory;
#[cfg(feature = "sqlite")]
pub mod sqlite;
#[cfg(feature = "redb")]
pub mod redb;
#[cfg(feature = "fjall")]
pub mod fjall;
#[cfg(feature = "lmdb")]
pub mod lmdb;
#[cfg(feature = "geomerge")]
pub mod geomerge;

View File

@ -0,0 +1,257 @@
//! fjall adapter.
//!
//! Each relation gets a fjall [`PartitionHandle`](fjall::PartitionHandle) of
//! the same name. A reserved partition named `__meta` carries per-relation
//! metadata (arity and next synthetic row ID).
//!
//! fjall has no native cross-partition write transactions, so the adapter
//! buffers inserts inside [`FjallTx`] and applies them when
//! [`Transaction::commit`] is called; dropping the tx without commit discards
//! the buffer.
use std::collections::HashMap;
use fjall::{Keyspace, PartitionCreateOptions, PartitionHandle};
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row};
use crate::id::RowId;
use crate::value::Value;
use crate::{CommittedTx, RowStream, Storage, StorageError, Transaction, backend};
const META_PARTITION: &str = "__meta";
/// fjall-backed [`Storage`] implementation.
pub struct FjallStorage {
keyspace: Keyspace,
meta: PartitionHandle,
}
impl FjallStorage {
/// Open or create a fjall keyspace at `path`.
///
/// # Errors
/// Returns [`StorageError::Backend`] if fjall fails to open the path.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
let keyspace = fjall::Config::new(path).open().map_err(backend)?;
let meta = keyspace
.open_partition(META_PARTITION, PartitionCreateOptions::default())
.map_err(backend)?;
Ok(Self { keyspace, meta })
}
fn relation_partition(&self, name: &str) -> Result<PartitionHandle, StorageError> {
self.keyspace
.open_partition(name, PartitionCreateOptions::default())
.map_err(backend)
}
}
impl Storage for FjallStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_PARTITION {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
if self.meta.contains_key(name.as_bytes()).map_err(backend)? {
return Err(StorageError::RelationExists(name.to_string()));
}
let Ok(arity_u32) = u32::try_from(arity) else {
unreachable!("arity exceeds u32::MAX")
};
self.meta
.insert(name.as_bytes(), encode_meta(arity_u32, 0))
.map_err(backend)?;
let _ = self.relation_partition(name)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let raw = self
.meta
.get(name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let (arity, _) = decode_meta(raw.as_ref())?;
Ok(arity as usize)
}
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError> {
let _ = self.arity(name)?;
let partition = self.relation_partition(name)?;
let iter = partition.iter().map(|res| {
let (key, value) = res.map_err(backend)?;
Ok((RowId::new(key.as_ref()), decode_row(value.as_ref())?))
});
Ok(Box::new(iter))
}
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError> {
Ok(Box::new(FjallTx {
keyspace: &self.keyspace,
meta: &self.meta,
pending: Vec::new(),
deletes: Vec::new(),
next_ids: HashMap::new(),
}))
}
}
pub(crate) struct FjallTx<'a> {
keyspace: &'a Keyspace,
meta: &'a PartitionHandle,
pending: Vec<(String, RowId, Vec<Value>)>,
deletes: Vec<(String, RowId)>,
next_ids: HashMap<String, (u32, u64)>,
}
impl FjallTx<'_> {
fn meta_for(&mut self, name: &str) -> Result<(u32, u64), StorageError> {
if let Some(&entry) = self.next_ids.get(name) {
return Ok(entry);
}
let raw = self
.meta
.get(name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let entry = decode_meta(raw.as_ref())?;
self.next_ids.insert(name.to_string(), entry);
Ok(entry)
}
}
impl Transaction for FjallTx<'_> {
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
let (arity, next_id) = self.meta_for(name)?;
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
let id = RowId::from(next_id);
self.next_ids.insert(name.to_string(), (arity, next_id + 1));
self.pending.push((name.to_string(), id.clone(), row));
Ok(id)
}
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
if !self.meta.contains_key(name.as_bytes()).map_err(backend)? {
return Err(StorageError::RelationNotFound(name.to_string()));
}
self.deletes.push((name.to_string(), id.clone()));
Ok(())
}
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError> {
let FjallTx {
keyspace,
meta,
pending,
deletes,
next_ids,
} = *self;
for (name, id, row) in pending {
let partition = keyspace
.open_partition(&name, PartitionCreateOptions::default())
.map_err(backend)?;
partition
.insert(id.as_bytes(), encode_row(&row))
.map_err(backend)?;
}
for (name, id) in deletes {
let partition = keyspace
.open_partition(&name, PartitionCreateOptions::default())
.map_err(backend)?;
partition.remove(id.as_bytes()).map_err(backend)?;
}
for (name, (arity, next_id)) in next_ids {
meta.insert(name.as_bytes(), encode_meta(arity, next_id))
.map_err(backend)?;
}
Ok(CommittedTx::empty())
}
}
#[cfg(test)]
mod tests {
use super::{FjallStorage, backend};
use crate::value::Value;
use crate::{Storage, StorageError};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn open_temp() -> Result<FjallStorage, StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let storage = FjallStorage::open(dir.path())?;
std::mem::forget(dir);
Ok(storage)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
let id0 = storage.insert("edge", vec![i(1), i(2)])?;
let id1 = storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(id0, vec![i(1), i(2)]), (id1, vec![i(2), i(3)])]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn batched_inserts_share_one_commit() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
let (a, b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1), i(2)])?;
let b = tx.insert("edge", vec![i(3), i(4)])?;
tx.commit()?;
(a, b)
};
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(a, vec![i(1), i(2)]), (b, vec![i(3), i(4)])]);
Ok(())
}
#[test]
fn dropped_transaction_is_rolled_back() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
tx.insert("edge", vec![i(1), i(2)])?;
}
assert!(storage.scan("edge")?.is_empty());
Ok(())
}
#[test]
fn delete_removes_row() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 1)?;
let a = storage.insert("edge", vec![i(1)])?;
let b = storage.insert("edge", vec![i(2)])?;
storage.delete("edge", &a)?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(b, vec![i(2)])]);
storage.delete("edge", &a)?;
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
}

View File

@ -0,0 +1,458 @@
//! Geomerge adapter.
//!
//! Geomerge schemas are immutable after store construction: there is no
//! public API to register a new table on a live `Store`. The adapter expects
//! all relations to be declared up front via a `FlatTheory` passed to
//! [`GeomergeStorage::from_theory`] (or a pre-built `Store` via
//! [`GeomergeStorage::from_store`]). [`Storage::create_relation`] is then a
//! verifier that the relation exists and that its arity matches.
//!
//! ## Deletion
//!
//! Geomerge upstream's `txn::ops::Op` enum currently has only `Op::Add`;
//! there is no retract or remove op. The adapter therefore uses the default
//! [`Transaction::delete`] implementation, which returns
//! [`StorageError::Backend`].
//!
//! ## Row Identifier Encoding
//!
//! Geomerge's [`RowId`](geomerge::table::RowId) is `{ commit: CommitHash, counter: u32 }`.
//! The adapter uses two encodings under [`crate::id::RowId`]:
//!
//! - **Existing (36 bytes):** 32-byte commit hash followed by a 4-byte BE
//! counter. Returned by [`Storage::scan`] and stable across calls.
//! - **Pending (4 bytes):** just a 4-byte BE counter, returned by
//! [`Transaction::insert`] to reference an in-flight row from later
//! inserts in the same transaction. Pending ids become invalid after
//! commit; post-commit identifiers must be looked up via
//! [`Storage::scan`].
//!
//! Foreign-key references inside a transaction work because geomerge's
//! `TxnCellValue::Id(RowRef::Pending(TempRowId(counter)))` accepts the
//! counter the adapter tracked locally. The local counter is assumed to
//! match geomerge's internal `TempRowId` counter (both start at 0 and
//! increment per `Transaction::add`).
use std::collections::{HashMap, HashSet};
use geomerge::commit::hash::CommitHash;
use geomerge::ir::{self, Path};
use geomerge::store::Store;
use geomerge::table::{CellValue, RowId as GmRowId};
use geomerge::txn::ops::{RowRef, TempRowId, TxnCellValue};
use crate::id::RowId;
use crate::value::Value;
use crate::{CommittedTx, RowStream, Storage, StorageError, Transaction, backend};
const GM_ROW_ID_LEN: usize = 32 + 4;
const PENDING_ROW_ID_LEN: usize = 4;
fn validation(msg: impl Into<String>) -> StorageError {
StorageError::Validation(msg.into())
}
fn encode_gm_row_id(id: &GmRowId) -> RowId {
let mut bytes = [0u8; GM_ROW_ID_LEN];
bytes[..32].copy_from_slice(&id.commit.0);
bytes[32..].copy_from_slice(&id.counter.to_be_bytes());
RowId::new(bytes)
}
fn decode_gm_row_id(bytes: &[u8]) -> Result<GmRowId, StorageError> {
if bytes.len() != GM_ROW_ID_LEN {
return Err(validation(format!(
"expected {GM_ROW_ID_LEN}-byte geomerge RowId, got {} bytes",
bytes.len()
)));
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes[..32]);
let mut counter_buf = [0u8; 4];
counter_buf.copy_from_slice(&bytes[32..]);
Ok(GmRowId {
commit: CommitHash(hash),
counter: u32::from_be_bytes(counter_buf),
})
}
fn encode_pending_row_id(counter: u32) -> RowId {
RowId::new(counter.to_be_bytes())
}
fn decode_pending_row_id(bytes: &[u8]) -> Result<TempRowId, StorageError> {
if bytes.len() != PENDING_ROW_ID_LEN {
return Err(validation(format!(
"expected {PENDING_ROW_ID_LEN}-byte pending RowId, got {} bytes",
bytes.len()
)));
}
let mut counter_buf = [0u8; 4];
counter_buf.copy_from_slice(bytes);
Ok(TempRowId::from(u32::from_be_bytes(counter_buf)))
}
/// Geomerge-backed [`Storage`] implementation.
pub struct GeomergeStorage {
store: Store,
declared: HashSet<String>,
}
impl Default for GeomergeStorage {
fn default() -> Self {
Self::new()
}
}
impl GeomergeStorage {
/// Build an empty store. No relations are available until the store is
/// rebuilt via a theory.
#[must_use]
pub fn new() -> Self {
Self {
store: Store::new(),
declared: HashSet::new(),
}
}
/// Build a store from a pre-defined `FlatTheory`. All `create_relation`
/// calls must reference relations declared in the theory.
///
/// # Errors
/// Returns [`StorageError::Backend`] if geomerge rejects the theory.
pub fn from_theory(theory: ir::FlatTheory) -> Result<Self, StorageError> {
let store = Store::try_from_theory(theory).map_err(|e| backend(*e))?;
Ok(Self {
store,
declared: HashSet::new(),
})
}
/// Wrap an existing `Store`, e.g. after decoding via
/// `geomerge::commit::pst::decode_store`.
#[must_use]
pub fn from_store(store: Store) -> Self {
Self {
store,
declared: HashSet::new(),
}
}
/// Borrow the underlying geomerge store (for backend-specific operations
/// like persistence, dump, or law inspection that aren't on the trait).
#[must_use]
pub fn store(&self) -> &Store {
&self.store
}
}
impl Storage for GeomergeStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if self.declared.contains(name) {
return Err(StorageError::RelationExists(name.to_string()));
}
let path: Path = name.into();
let table = self.store.table_at(&path).ok_or_else(|| {
validation(format!(
"relation '{name}' is not declared in the loaded geomerge theory; \
geomerge does not support runtime relation creation"
))
})?;
let declared_arity = table.schema().columns.len();
if declared_arity != arity {
return Err(StorageError::ArityMismatch {
expected: declared_arity,
got: arity,
});
}
self.declared.insert(name.to_string());
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let path: Path = name.into();
let table = self
.store
.table_at(&path)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
Ok(table.schema().columns.len())
}
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError> {
let path: Path = name.into();
let table = self
.store
.table_at(&path)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let arity = table.schema().columns.len();
let mut rows = Vec::with_capacity(table.row_count());
for r in 0..table.row_count() {
let gm_id = table
.row_id_at(r)
.ok_or_else(|| validation(format!("missing row id at {r} in '{name}'")))?;
let id = encode_gm_row_id(&gm_id);
let mut row = Vec::with_capacity(arity);
for c in 0..arity {
let cell = table
.cell_at(r, c)
.ok_or_else(|| validation(format!("missing cell at ({r}, {c}) in '{name}'")))?;
row.push(cell_to_value(cell));
}
rows.push((id, row));
}
Ok(Box::new(rows.into_iter().map(Ok)))
}
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError> {
let txn = self.store.transaction();
Ok(Box::new(GeomergeTx {
txn: Some(txn),
counter: 0,
}))
}
}
pub(crate) struct GeomergeTx<'a> {
txn: Option<geomerge::txn::Transaction<'a>>,
/// Mirrors geomerge's internal `TempRowId` counter for this transaction.
/// Both start at 0 and increment by 1 per `Transaction::add`.
counter: u32,
}
impl Transaction for GeomergeTx<'_> {
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
let path: Path = name.into();
let values: Vec<TxnCellValue> = row
.into_iter()
.map(value_to_txn_cell)
.collect::<Result<Vec<_>, _>>()?;
let Some(txn) = self.txn.as_mut() else {
unreachable!("transaction was already committed")
};
txn.add(&path, values)
.map_err(|e| validation(e.to_string()))?;
let id = encode_pending_row_id(self.counter);
self.counter += 1;
Ok(id)
}
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError> {
let mut this = self;
let Some(txn) = this.txn.take() else {
unreachable!("transaction was already committed")
};
// Law violations (totality, foreign-key, etc.) surface here.
let commit_hash = txn.commit().map_err(|e| validation(e.to_string()))?;
// Every counter we returned during this tx (0..self.counter) now
// corresponds to a real RowId { commit: commit_hash, counter }.
let mut resolutions = HashMap::with_capacity(this.counter as usize);
for counter in 0..this.counter {
let pending = encode_pending_row_id(counter);
let real = encode_gm_row_id(&GmRowId {
commit: commit_hash,
counter,
});
resolutions.insert(pending, real);
}
Ok(CommittedTx::from_mappings(resolutions))
}
}
fn cell_to_value(cell: &CellValue) -> Value {
match cell {
CellValue::Int(i) => Value::Int(*i),
CellValue::Str(s) => Value::Str(s.clone()),
CellValue::Id(id) => Value::Id(encode_gm_row_id(id)),
}
}
fn value_to_txn_cell(value: Value) -> Result<TxnCellValue, StorageError> {
match value {
Value::Int(i) => Ok(TxnCellValue::Int(i)),
Value::Str(s) => Ok(TxnCellValue::Str(s)),
Value::Id(id) => {
let bytes = id.as_bytes();
match bytes.len() {
PENDING_ROW_ID_LEN => Ok(TxnCellValue::Id(RowRef::Pending(decode_pending_row_id(
bytes,
)?))),
GM_ROW_ID_LEN => Ok(TxnCellValue::Id(RowRef::Existing(decode_gm_row_id(bytes)?))),
len => Err(validation(format!(
"geomerge RowId must be {PENDING_ROW_ID_LEN} (pending) or \
{GM_ROW_ID_LEN} (existing) bytes, got {len}"
))),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use geomerge::ir::{ColType, FlatTheory, PrimType, Schema, TableEntry};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn int_schema(arity: usize) -> Schema {
Schema {
columns: (0..arity)
.map(|_| ColType::PrimType {
prim: PrimType::PrimInt,
})
.collect(),
primary_key: None,
}
}
fn theory_with_one_int_table(name: &str, arity: usize) -> FlatTheory {
FlatTheory {
tables: vec![TableEntry {
path: name.into(),
table: int_schema(arity),
}],
laws: Vec::new(),
}
}
#[test]
fn empty_store_has_no_relations() {
let storage = GeomergeStorage::new();
assert!(matches!(
storage.arity("edge"),
Err(StorageError::RelationNotFound(_))
));
}
#[test]
fn create_relation_on_undeclared_returns_validation_error() {
let mut storage = GeomergeStorage::new();
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::Validation(_))
));
}
#[test]
fn theory_loaded_insert_scan_roundtrip() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
storage.insert("edge", vec![i(3), i(4)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].1, vec![i(1), i(2)]);
assert_eq!(rows[1].1, vec![i(3), i(4)]);
// Scanned IDs use the 36-byte existing form.
assert_eq!(rows[0].0.as_bytes().len(), GM_ROW_ID_LEN);
assert_eq!(rows[1].0.as_bytes().len(), GM_ROW_ID_LEN);
Ok(())
}
#[test]
fn single_storage_insert_returns_post_commit_row_id() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 1);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 1)?;
// `Storage::insert` opens its own tx, commits, and resolves the
// pending RowId to its post-commit form. The returned id should be
// the 36-byte (existing) shape, not the 4-byte (pending) shape.
let id = storage.insert("edge", vec![i(1)])?;
assert_eq!(id.as_bytes().len(), GM_ROW_ID_LEN);
// And it should equal what scan returns.
let rows = storage.scan("edge")?;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, id);
Ok(())
}
#[test]
fn committed_tx_resolves_pending_ids() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 1);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 1)?;
let (committed, pending_a, pending_b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1)])?;
let b = tx.insert("edge", vec![i(2)])?;
(tx.commit()?, a, b)
};
let real_a = committed.resolve(&pending_a);
let real_b = committed.resolve(&pending_b);
assert_eq!(real_a.as_bytes().len(), GM_ROW_ID_LEN);
assert_eq!(real_b.as_bytes().len(), GM_ROW_ID_LEN);
assert_ne!(real_a, real_b);
// The resolved ids should match what scan reports.
let rows = storage.scan("edge")?;
assert!(rows.iter().any(|(id, _)| id == &real_a));
assert!(rows.iter().any(|(id, _)| id == &real_b));
Ok(())
}
#[test]
fn batched_inserts_in_one_transaction() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
let p0 = tx.insert("edge", vec![i(1), i(2)])?;
let p1 = tx.insert("edge", vec![i(3), i(4)])?;
// Pending ids are 4-byte counters within the tx.
assert_eq!(p0.as_bytes().len(), PENDING_ROW_ID_LEN);
assert_eq!(p1.as_bytes().len(), PENDING_ROW_ID_LEN);
tx.commit()?;
}
let rows = storage.scan("edge")?;
assert_eq!(rows.len(), 2);
Ok(())
}
#[test]
fn dropped_transaction_is_rolled_back() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
tx.insert("edge", vec![i(1), i(2)])?;
}
assert!(storage.scan("edge")?.is_empty());
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn delete_is_not_supported() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 1);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 1)?;
let id = storage.insert("edge", vec![i(1)])?;
let result = storage.delete("edge", &id);
assert!(matches!(result, Err(StorageError::Unsupported(_))));
Ok(())
}
#[test]
fn insert_wrong_type_returns_validation_error() -> Result<(), StorageError> {
let theory = theory_with_one_int_table("edge", 2);
let mut storage = GeomergeStorage::from_theory(theory)?;
storage.create_relation("edge", 2)?;
let result = storage.insert("edge", vec![Value::Str("not an int".to_string()), i(2)]);
assert!(matches!(result, Err(StorageError::Validation(_))));
Ok(())
}
}

View File

@ -0,0 +1,326 @@
//! LMDB adapter via the `heed` crate.
//!
//! Maps each relation onto a named LMDB sub-database of the same name. A
//! reserved sub-database named `__meta` carries per-relation metadata (arity
//! and next synthetic row ID).
//!
//! [`LmdbTx`] wraps a real `heed::RwTxn`. Inserts go through the transaction;
//! [`Transaction::commit`] commits it; dropping the tx without commit lets
//! `heed` abort the transaction.
use std::collections::HashMap;
use heed::types::Bytes;
use heed::{Database, Env, EnvOpenOptions, RwTxn};
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row, row_key};
use crate::id::RowId;
use crate::value::Value;
use crate::{CommittedTx, RowStream, Storage, StorageError, Transaction, backend};
const META_DB: &str = "__meta";
const DEFAULT_MAX_DBS: u32 = 128;
const DEFAULT_MAP_SIZE: usize = 100 * 1024 * 1024;
/// LMDB-backed [`Storage`] implementation.
pub struct LmdbStorage {
env: Env,
meta: Database<Bytes, Bytes>,
}
impl LmdbStorage {
/// Open or create an LMDB environment at `path`.
///
/// # Errors
/// Returns [`StorageError::Backend`] if LMDB fails to open.
///
/// # Safety
/// Uses `EnvOpenOptions::open`, which `heed` marks unsafe because the
/// memory-mapped file's contents can be modified by other processes,
/// violating Rust's aliasing rules. This adapter assumes single-process
/// exclusive access to the path.
#[allow(unsafe_code)]
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
// SAFETY: see method-level doc above.
let env = unsafe {
EnvOpenOptions::new()
.max_dbs(DEFAULT_MAX_DBS)
.map_size(DEFAULT_MAP_SIZE)
.open(path)
.map_err(backend)?
};
let mut wtxn = env.write_txn().map_err(backend)?;
let meta: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some(META_DB))
.map_err(backend)?;
wtxn.commit().map_err(backend)?;
Ok(Self { env, meta })
}
}
impl Storage for LmdbStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_DB {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
let Ok(arity_u32) = u32::try_from(arity) else {
unreachable!("arity exceeds u32::MAX")
};
let mut wtxn = self.env.write_txn().map_err(backend)?;
if self
.meta
.get(&wtxn, name.as_bytes())
.map_err(backend)?
.is_some()
{
return Err(StorageError::RelationExists(name.to_string()));
}
let encoded = encode_meta(arity_u32, 0);
self.meta
.put(&mut wtxn, name.as_bytes(), &encoded[..])
.map_err(backend)?;
let _ = self
.env
.create_database::<Bytes, Bytes>(&mut wtxn, Some(name))
.map_err(backend)?;
wtxn.commit().map_err(backend)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let rtxn = self.env.read_txn().map_err(backend)?;
let raw = self
.meta
.get(&rtxn, name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let (arity, _) = decode_meta(raw)?;
Ok(arity as usize)
}
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError> {
let rtxn = self.env.read_txn().map_err(backend)?;
if self
.meta
.get(&rtxn, name.as_bytes())
.map_err(backend)?
.is_none()
{
return Err(StorageError::RelationNotFound(name.to_string()));
}
let db: Database<Bytes, Bytes> = self
.env
.open_database(&rtxn, Some(name))
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let mut rows = Vec::new();
for entry in db.iter(&rtxn).map_err(backend)? {
let (key, value) = entry.map_err(backend)?;
rows.push((RowId::new(key), decode_row(value)?));
}
Ok(Box::new(rows.into_iter().map(Ok)))
}
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError> {
let wtxn = self.env.write_txn().map_err(backend)?;
Ok(Box::new(LmdbTx {
env: &self.env,
meta: self.meta,
wtxn: Some(wtxn),
dbs: HashMap::new(),
next_ids: HashMap::new(),
}))
}
}
pub(crate) struct LmdbTx<'a> {
env: &'a Env,
meta: Database<Bytes, Bytes>,
wtxn: Option<RwTxn<'a>>,
/// Per-relation sub-database handles opened within this transaction.
dbs: HashMap<String, Database<Bytes, Bytes>>,
next_ids: HashMap<String, (u32, u64)>,
}
impl Transaction for LmdbTx<'_> {
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
// Load meta on first access to this relation; subsequent calls within
// the tx read the cached entry.
let (arity, next_id) = if let Some(&entry) = self.next_ids.get(name) {
entry
} else {
let Some(wtxn) = self.wtxn.as_ref() else {
unreachable!("transaction was already committed")
};
let raw = self
.meta
.get(wtxn, name.as_bytes())
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let entry = decode_meta(raw)?;
self.next_ids.insert(name.to_string(), entry);
entry
};
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
// Open the per-relation sub-database (cached for subsequent inserts).
let db = if let Some(&db) = self.dbs.get(name) {
db
} else {
let Some(wtxn) = self.wtxn.as_mut() else {
unreachable!("transaction was already committed")
};
let db = self
.env
.create_database::<Bytes, Bytes>(wtxn, Some(name))
.map_err(backend)?;
self.dbs.insert(name.to_string(), db);
db
};
let key = row_key(next_id);
let value = encode_row(&row);
let Some(wtxn) = self.wtxn.as_mut() else {
unreachable!("transaction was already committed")
};
db.put(wtxn, &key[..], &value[..]).map_err(backend)?;
self.next_ids.insert(name.to_string(), (arity, next_id + 1));
Ok(RowId::from(next_id))
}
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
// Verify relation existence via meta.
let Some(wtxn) = self.wtxn.as_ref() else {
unreachable!("transaction was already committed")
};
if self
.meta
.get(wtxn, name.as_bytes())
.map_err(backend)?
.is_none()
{
return Err(StorageError::RelationNotFound(name.to_string()));
}
// Open or reuse the per-relation sub-database.
let db = if let Some(&db) = self.dbs.get(name) {
db
} else {
let Some(wtxn) = self.wtxn.as_mut() else {
unreachable!("transaction was already committed")
};
let db = self
.env
.create_database::<Bytes, Bytes>(wtxn, Some(name))
.map_err(backend)?;
self.dbs.insert(name.to_string(), db);
db
};
let Some(wtxn) = self.wtxn.as_mut() else {
unreachable!("transaction was already committed")
};
let _ = db.delete(wtxn, id.as_bytes()).map_err(backend)?;
Ok(())
}
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError> {
let mut this = self;
let Some(mut wtxn) = this.wtxn.take() else {
unreachable!("transaction was already committed")
};
for (name, (arity, next_id)) in this.next_ids.drain() {
let encoded = encode_meta(arity, next_id);
this.meta
.put(&mut wtxn, name.as_bytes(), &encoded[..])
.map_err(backend)?;
}
wtxn.commit().map_err(backend)?;
Ok(CommittedTx::empty())
}
}
#[cfg(test)]
mod tests {
use super::{LmdbStorage, backend};
use crate::value::Value;
use crate::{Storage, StorageError};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn open_temp() -> Result<LmdbStorage, StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let storage = LmdbStorage::open(dir.path())?;
std::mem::forget(dir);
Ok(storage)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
let id0 = storage.insert("edge", vec![i(1), i(2)])?;
let id1 = storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(id0, vec![i(1), i(2)]), (id1, vec![i(2), i(3)])]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn batched_inserts_share_one_commit() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
let (a, b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1), i(2)])?;
let b = tx.insert("edge", vec![i(3), i(4)])?;
tx.commit()?;
(a, b)
};
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(a, vec![i(1), i(2)]), (b, vec![i(3), i(4)])]);
Ok(())
}
#[test]
fn dropped_transaction_is_rolled_back() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
tx.insert("edge", vec![i(1), i(2)])?;
}
assert!(storage.scan("edge")?.is_empty());
Ok(())
}
#[test]
fn delete_removes_row() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 1)?;
let a = storage.insert("edge", vec![i(1)])?;
let b = storage.insert("edge", vec![i(2)])?;
storage.delete("edge", &a)?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(b, vec![i(2)])]);
storage.delete("edge", &a)?;
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
}

View File

@ -0,0 +1,343 @@
//! In-memory backend, keyed by relation name. Always available.
use std::collections::HashMap;
use crate::id::RowId;
use crate::value::Value;
use crate::{CommittedTx, RowStream, Storage, StorageError, Transaction};
/// In-memory backend, useful as the default in tests and as a correctness
/// oracle for other backends.
#[derive(Debug, Default)]
pub struct MemoryStorage {
relations: HashMap<String, MemoryRelation>,
}
#[derive(Debug)]
pub(crate) struct MemoryRelation {
pub(crate) arity: usize,
pub(crate) next_id: u64,
pub(crate) rows: Vec<(RowId, Vec<Value>)>,
}
impl MemoryStorage {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl Storage for MemoryStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if self.relations.contains_key(name) {
return Err(StorageError::RelationExists(name.to_string()));
}
self.relations.insert(
name.to_string(),
MemoryRelation {
arity,
next_id: 0,
rows: Vec::new(),
},
);
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
self.relations
.get(name)
.map(|r| r.arity)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))
}
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError> {
let relation = self
.relations
.get(name)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
Ok(Box::new(relation.rows.iter().cloned().map(Ok)))
}
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError> {
Ok(Box::new(MemoryTx {
storage: self,
next_ids: HashMap::new(),
pending: Vec::new(),
deletes: Vec::new(),
}))
}
}
/// In-flight memory transaction. Buffers inserts and deletes; applies on commit.
pub(crate) struct MemoryTx<'a> {
storage: &'a mut MemoryStorage,
/// Local next-id-per-relation; initialized lazily from storage on first
/// insert into a relation, then incremented per buffered row.
next_ids: HashMap<String, u64>,
/// (relation name, assigned `RowId`, row cells) for each buffered insert.
pending: Vec<(String, RowId, Vec<Value>)>,
/// (relation name, `RowId`) for each buffered delete. Applied after
/// inserts on commit, so insert+delete of the same id in one tx is a
/// net no-op.
deletes: Vec<(String, RowId)>,
}
impl MemoryTx<'_> {
fn next_id_for(&mut self, name: &str) -> Result<u64, StorageError> {
if let Some(id) = self.next_ids.get(name) {
return Ok(*id);
}
let relation = self
.storage
.relations
.get(name)
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let id = relation.next_id;
self.next_ids.insert(name.to_string(), id);
Ok(id)
}
}
impl Transaction for MemoryTx<'_> {
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
let arity = self.storage.arity(name)?;
if row.len() != arity {
return Err(StorageError::ArityMismatch {
expected: arity,
got: row.len(),
});
}
let next_id = self.next_id_for(name)?;
let id = RowId::from(next_id);
self.next_ids.insert(name.to_string(), next_id + 1);
self.pending.push((name.to_string(), id.clone(), row));
Ok(id)
}
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
// Verify the relation exists; the actual removal is deferred to commit.
let _ = self.storage.arity(name)?;
self.deletes.push((name.to_string(), id.clone()));
Ok(())
}
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError> {
let MemoryTx {
storage,
next_ids,
pending,
deletes,
} = *self;
for (name, id, row) in pending {
let relation = storage
.relations
.get_mut(&name)
.ok_or_else(|| StorageError::RelationNotFound(name.clone()))?;
relation.rows.push((id, row));
}
for (name, id) in deletes {
let relation = storage
.relations
.get_mut(&name)
.ok_or_else(|| StorageError::RelationNotFound(name.clone()))?;
relation.rows.retain(|(rid, _)| rid != &id);
}
for (name, next_id) in next_ids {
if let Some(relation) = storage.relations.get_mut(&name) {
relation.next_id = next_id;
}
}
// Pending RowIds returned during the tx are already the real ids.
Ok(CommittedTx::empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan_as_table;
fn i(x: i64) -> Value {
Value::Int(x)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
let id0 = storage.insert("edge", vec![i(1), i(2)])?;
let id1 = storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(id0, vec![i(1), i(2)]), (id1, vec![i(2), i(3)])],);
Ok(())
}
#[test]
fn batched_inserts_share_one_commit() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
let (a, b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1), i(2)])?;
let b = tx.insert("edge", vec![i(3), i(4)])?;
tx.commit()?;
(a, b)
};
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(a, vec![i(1), i(2)]), (b, vec![i(3), i(4)])],);
Ok(())
}
#[test]
fn dropped_transaction_is_rolled_back() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
tx.insert("edge", vec![i(1), i(2)])?;
tx.insert("edge", vec![i(3), i(4)])?;
// dropped without commit
}
let rows = storage.scan("edge")?;
assert!(rows.is_empty());
Ok(())
}
#[test]
fn inserted_row_ids_are_distinct_and_increment() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 1)?;
let id0 = storage.insert("edge", vec![i(1)])?;
let id1 = storage.insert("edge", vec![i(2)])?;
assert_ne!(id0, id1);
assert_eq!(id0, RowId::from(0u64));
assert_eq!(id1, RowId::from(1u64));
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn scan_unknown_relation_returns_err() {
let storage = MemoryStorage::new();
assert!(matches!(
storage.scan("missing"),
Err(StorageError::RelationNotFound(_))
));
}
#[test]
fn arity_unknown_relation_returns_err() {
let storage = MemoryStorage::new();
assert!(matches!(
storage.arity("missing"),
Err(StorageError::RelationNotFound(_))
));
}
#[test]
fn insert_wrong_arity_returns_err() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.insert("edge", vec![i(1)]),
Err(StorageError::ArityMismatch {
expected: 2,
got: 1
})
));
Ok(())
}
#[test]
fn delete_removes_row_then_idempotent_on_missing() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 1)?;
let a = storage.insert("edge", vec![i(1)])?;
let b = storage.insert("edge", vec![i(2)])?;
storage.delete("edge", &a)?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(b.clone(), vec![i(2)])]);
// Idempotent: deleting `a` again is fine.
storage.delete("edge", &a)?;
assert_eq!(storage.scan("edge")?, vec![(b, vec![i(2)])]);
Ok(())
}
#[test]
fn delete_within_transaction_is_atomic() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 1)?;
let a = storage.insert("edge", vec![i(1)])?;
let _b = storage.insert("edge", vec![i(2)])?;
{
let mut tx = storage.transaction()?;
tx.delete("edge", &a)?;
// Drop without commit: deletion rolled back.
}
assert_eq!(storage.scan("edge")?.len(), 2);
Ok(())
}
#[test]
fn scan_where_filters_by_column_value() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(10)])?;
let target = storage.insert("edge", vec![i(2), i(20)])?;
storage.insert("edge", vec![i(3), i(10)])?;
let target2 = storage.insert("edge", vec![i(2), i(30)])?;
// Filter on column 0 = 2.
let matches: Vec<_> = storage
.scan_where("edge", 0, &i(2))?
.collect::<Result<_, _>>()?;
assert_eq!(
matches,
vec![(target, vec![i(2), i(20)]), (target2, vec![i(2), i(30)])],
);
// Out-of-range column = no matches.
let none: Vec<_> = storage
.scan_where("edge", 5, &i(2))?
.collect::<Result<_, _>>()?;
assert!(none.is_empty());
Ok(())
}
#[test]
fn scan_iter_yields_rows_lazily() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 1)?;
storage.insert("edge", vec![i(10)])?;
storage.insert("edge", vec![i(20)])?;
storage.insert("edge", vec![i(30)])?;
// Take only the first two rows without scanning the whole relation.
let prefix: Vec<_> = storage
.scan_iter("edge")?
.take(2)
.collect::<Result<_, _>>()?;
assert_eq!(prefix.len(), 2);
assert_eq!(prefix[0].1, vec![i(10)]);
assert_eq!(prefix[1].1, vec![i(20)]);
Ok(())
}
#[test]
fn scan_as_table_drops_row_ids() -> Result<(), StorageError> {
let mut storage = MemoryStorage::new();
storage.create_relation("edge", 2)?;
storage.insert("edge", vec![i(1), i(2)])?;
let table = scan_as_table(&storage, "edge")?;
assert_eq!(table.arity, 2);
assert_eq!(table.rows, vec![vec![i(1), i(2)]]);
Ok(())
}
}

View File

@ -0,0 +1,302 @@
//! redb adapter.
//!
//! Each relation gets a redb table named after it, keyed by `u64` row IDs.
//! A reserved table named `__meta`, keyed by relation name, carries per-relation
//! metadata (arity and next synthetic row ID).
//!
//! [`RedbTx`] wraps a real `redb::WriteTransaction`. Inserts go through the
//! transaction; [`Transaction::commit`] commits it; dropping the tx without
//! commit rolls back (redb's `WriteTransaction` drops the pending writes).
use std::collections::HashMap;
use redb::{Database, ReadableTable, TableDefinition, WriteTransaction};
use crate::codec::{decode_meta, decode_row, encode_meta, encode_row};
use crate::id::RowId;
use crate::value::Value;
use crate::{CommittedTx, RowStream, Storage, StorageError, Transaction, backend};
const META_TABLE: &str = "__meta";
fn meta_def() -> TableDefinition<'static, &'static str, &'static [u8]> {
TableDefinition::new(META_TABLE)
}
fn rows_def(name: &str) -> TableDefinition<'_, u64, &'static [u8]> {
TableDefinition::new(name)
}
/// redb-backed [`Storage`] implementation.
pub struct RedbStorage {
db: Database,
}
impl RedbStorage {
/// Open or create a redb database at `path`.
///
/// # Errors
/// Returns [`StorageError::Backend`] if redb fails to open the file.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
let db = Database::create(path).map_err(backend)?;
Ok(Self { db })
}
}
impl Storage for RedbStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
if name == META_TABLE {
return Err(StorageError::Validation(format!(
"relation name '{name}' is reserved"
)));
}
let Ok(arity_u32) = u32::try_from(arity) else {
unreachable!("arity exceeds u32::MAX")
};
let txn = self.db.begin_write().map_err(backend)?;
{
let mut meta = txn.open_table(meta_def()).map_err(backend)?;
if meta.get(name).map_err(backend)?.is_some() {
return Err(StorageError::RelationExists(name.to_string()));
}
let encoded = encode_meta(arity_u32, 0);
meta.insert(name, &encoded[..]).map_err(backend)?;
let _ = txn.open_table(rows_def(name)).map_err(backend)?;
}
txn.commit().map_err(backend)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let txn = self.db.begin_read().map_err(backend)?;
let meta = txn.open_table(meta_def()).map_err(backend)?;
let entry = meta
.get(name)
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
let (arity, _) = decode_meta(entry.value())?;
Ok(arity as usize)
}
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError> {
let txn = self.db.begin_read().map_err(backend)?;
let meta = txn.open_table(meta_def()).map_err(backend)?;
if meta.get(name).map_err(backend)?.is_none() {
return Err(StorageError::RelationNotFound(name.to_string()));
}
let table = txn.open_table(rows_def(name)).map_err(backend)?;
let mut rows = Vec::new();
for entry in table.iter().map_err(backend)? {
let (key, value) = entry.map_err(backend)?;
let id = RowId::from(key.value());
rows.push((id, decode_row(value.value())?));
}
Ok(Box::new(rows.into_iter().map(Ok)))
}
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError> {
let wtxn = self.db.begin_write().map_err(backend)?;
Ok(Box::new(RedbTx {
wtxn: Some(wtxn),
next_ids: HashMap::new(),
}))
}
}
pub(crate) struct RedbTx {
wtxn: Option<WriteTransaction>,
next_ids: HashMap<String, (u32, u64)>,
}
impl RedbTx {
/// Borrow the live `WriteTransaction`. Panics if commit already
/// consumed it: unreachable via the public API since
/// [`Transaction::commit`] consumes the boxed tx.
fn live(&self) -> &WriteTransaction {
match self.wtxn.as_ref() {
Some(t) => t,
None => unreachable!("transaction was already committed"),
}
}
fn meta_for(&mut self, name: &str) -> Result<(u32, u64), StorageError> {
if let Some(&entry) = self.next_ids.get(name) {
return Ok(entry);
}
let decoded = {
let meta = self.live().open_table(meta_def()).map_err(backend)?;
let entry = meta
.get(name)
.map_err(backend)?
.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
decode_meta(entry.value())?
};
self.next_ids.insert(name.to_string(), decoded);
Ok(decoded)
}
}
fn row_id_as_u64(id: &RowId) -> Result<u64, StorageError> {
let bytes = id.as_bytes();
if bytes.len() != 8 {
return Err(StorageError::Backend(
format!("redb row id must be 8 bytes, got {}", bytes.len()).into(),
));
}
let mut buf = [0u8; 8];
buf.copy_from_slice(bytes);
Ok(u64::from_be_bytes(buf))
}
impl Transaction for RedbTx {
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
let (arity, next_id) = self.meta_for(name)?;
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
{
let mut rows = self.live().open_table(rows_def(name)).map_err(backend)?;
let encoded = encode_row(&row);
rows.insert(next_id, &encoded[..]).map_err(backend)?;
}
self.next_ids.insert(name.to_string(), (arity, next_id + 1));
Ok(RowId::from(next_id))
}
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
let key = row_id_as_u64(id)?;
let wtxn = self.live();
// Verify the relation exists by checking meta.
let meta = wtxn.open_table(meta_def()).map_err(backend)?;
if meta.get(name).map_err(backend)?.is_none() {
return Err(StorageError::RelationNotFound(name.to_string()));
}
drop(meta);
let mut rows = wtxn.open_table(rows_def(name)).map_err(backend)?;
let _ = rows.remove(key).map_err(backend)?;
Ok(())
}
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError> {
let mut this = self;
let Some(wtxn) = this.wtxn.take() else {
unreachable!("transaction was already committed")
};
{
let mut meta = wtxn.open_table(meta_def()).map_err(backend)?;
for (name, (arity, next_id)) in this.next_ids.drain() {
let encoded = encode_meta(arity, next_id);
meta.insert(name.as_str(), &encoded[..]).map_err(backend)?;
}
}
wtxn.commit().map_err(backend)?;
Ok(CommittedTx::empty())
}
}
#[cfg(test)]
mod tests {
use super::{RedbStorage, backend};
use crate::value::Value;
use crate::{Storage, StorageError};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn s(x: &str) -> Value {
Value::Str(x.to_string())
}
fn open_temp() -> Result<RedbStorage, StorageError> {
let dir = tempfile::tempdir().map_err(backend)?;
let path = dir.path().join("test.redb");
let storage = RedbStorage::open(&path)?;
std::mem::forget(dir);
Ok(storage)
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
let id0 = storage.insert("edge", vec![i(1), i(2)])?;
let id1 = storage.insert("edge", vec![s("hello"), i(7)])?;
let rows = storage.scan("edge")?;
assert_eq!(
rows,
vec![(id0, vec![i(1), i(2)]), (id1, vec![s("hello"), i(7)])],
);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn batched_inserts_share_one_commit() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
let (a, b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1), i(2)])?;
let b = tx.insert("edge", vec![i(3), i(4)])?;
tx.commit()?;
(a, b)
};
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(a, vec![i(1), i(2)]), (b, vec![i(3), i(4)])]);
Ok(())
}
#[test]
fn dropped_transaction_is_rolled_back() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
tx.insert("edge", vec![i(1), i(2)])?;
}
assert!(storage.scan("edge")?.is_empty());
Ok(())
}
#[test]
fn delete_removes_row() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 1)?;
let a = storage.insert("edge", vec![i(1)])?;
let b = storage.insert("edge", vec![i(2)])?;
storage.delete("edge", &a)?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(b, vec![i(2)])]);
storage.delete("edge", &a)?;
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn insert_wrong_arity_returns_err() -> Result<(), StorageError> {
let mut storage = open_temp()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.insert("edge", vec![i(1)]),
Err(StorageError::ArityMismatch {
expected: 2,
got: 1,
})
));
Ok(())
}
}

View File

@ -0,0 +1,312 @@
//! SQLite adapter via the `rusqlite` crate (bundled libsqlite3).
//!
//! Storage layout:
//!
//! - `__meta(name TEXT PRIMARY KEY, arity INTEGER, next_id INTEGER)` tracks
//! per-relation metadata.
//! - `__rows(rel TEXT, row_id BLOB, row_bytes BLOB, PRIMARY KEY (rel, row_id))`
//! holds every row across every relation. The single-table layout avoids
//! per-relation DDL and keeps schema operations out of insert paths.
//!
//! [`SqliteTx`] wraps a real `rusqlite::Transaction`. Inserts and deletes
//! execute through the transaction; [`Transaction::commit`] commits it;
//! dropping the tx rolls back via rusqlite's `Transaction::drop`.
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
use crate::codec::{decode_row, encode_row};
use crate::id::RowId;
use crate::value::Value;
use crate::{CommittedTx, RowStream, Storage, StorageError, Transaction, backend};
const SCHEMA_SQL: &str = "
CREATE TABLE IF NOT EXISTS __meta (
name TEXT PRIMARY KEY NOT NULL,
arity INTEGER NOT NULL,
next_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS __rows (
rel TEXT NOT NULL,
row_id BLOB NOT NULL,
row_bytes BLOB NOT NULL,
PRIMARY KEY (rel, row_id)
);
";
/// SQLite-backed [`Storage`] implementation.
pub struct SqliteStorage {
conn: Connection,
}
impl SqliteStorage {
/// Open or create a SQLite database at `path`. Pass `":memory:"` for
/// an in-process database (useful in tests).
///
/// # Errors
/// Returns [`StorageError::Backend`] if `rusqlite` fails to open the
/// path or to initialize the schema.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, StorageError> {
let conn = Connection::open(path).map_err(backend)?;
conn.execute_batch(SCHEMA_SQL).map_err(backend)?;
Ok(Self { conn })
}
}
impl Storage for SqliteStorage {
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError> {
let Ok(arity_u32) = u32::try_from(arity) else {
unreachable!("arity exceeds u32::MAX")
};
let exists: bool = self
.conn
.query_row("SELECT 1 FROM __meta WHERE name = ?", params![name], |_| {
Ok(())
})
.optional()
.map_err(backend)?
.is_some();
if exists {
return Err(StorageError::RelationExists(name.to_string()));
}
self.conn
.execute(
"INSERT INTO __meta (name, arity, next_id) VALUES (?, ?, 0)",
params![name, arity_u32],
)
.map_err(backend)?;
Ok(())
}
fn arity(&self, name: &str) -> Result<usize, StorageError> {
let arity: Option<u32> = self
.conn
.query_row(
"SELECT arity FROM __meta WHERE name = ?",
params![name],
|row| row.get::<_, u32>(0),
)
.optional()
.map_err(backend)?;
let arity = arity.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
Ok(arity as usize)
}
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError> {
let _ = self.arity(name)?;
let mut stmt = self
.conn
.prepare("SELECT row_id, row_bytes FROM __rows WHERE rel = ? ORDER BY row_id")
.map_err(backend)?;
let mapped = stmt
.query_map(params![name], |row| {
let row_id: Vec<u8> = row.get(0)?;
let row_bytes: Vec<u8> = row.get(1)?;
Ok((row_id, row_bytes))
})
.map_err(backend)?;
let mut rows = Vec::new();
for entry in mapped {
let (id_bytes, value_bytes) = entry.map_err(backend)?;
rows.push((RowId::new(&id_bytes), decode_row(&value_bytes)?));
}
Ok(Box::new(rows.into_iter().map(Ok)))
}
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError> {
let txn = self.conn.transaction().map_err(backend)?;
Ok(Box::new(SqliteTx {
txn: Some(txn),
next_ids: HashMap::new(),
}))
}
}
pub(crate) struct SqliteTx<'a> {
txn: Option<rusqlite::Transaction<'a>>,
next_ids: HashMap<String, (u32, u64)>,
}
impl<'a> SqliteTx<'a> {
/// Borrow the live `rusqlite::Transaction`. Panics if commit already
/// consumed it: unreachable via the public API since
/// [`Transaction::commit`] consumes the boxed tx.
fn live(&self) -> &rusqlite::Transaction<'a> {
match self.txn.as_ref() {
Some(t) => t,
None => unreachable!("transaction was already committed"),
}
}
fn meta_for(&mut self, name: &str) -> Result<(u32, u64), StorageError> {
if let Some(&entry) = self.next_ids.get(name) {
return Ok(entry);
}
let entry: Option<(u32, u64)> = self
.live()
.query_row(
"SELECT arity, next_id FROM __meta WHERE name = ?",
params![name],
|row| Ok((row.get::<_, u32>(0)?, row.get::<_, u64>(1)?)),
)
.optional()
.map_err(backend)?;
let entry = entry.ok_or_else(|| StorageError::RelationNotFound(name.to_string()))?;
self.next_ids.insert(name.to_string(), entry);
Ok(entry)
}
}
impl Transaction for SqliteTx<'_> {
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
let (arity, next_id) = self.meta_for(name)?;
if row.len() != arity as usize {
return Err(StorageError::ArityMismatch {
expected: arity as usize,
got: row.len(),
});
}
let row_id = RowId::from(next_id);
let encoded = encode_row(&row);
self.live()
.execute(
"INSERT INTO __rows (rel, row_id, row_bytes) VALUES (?, ?, ?)",
params![name, row_id.as_bytes(), encoded],
)
.map_err(backend)?;
self.next_ids.insert(name.to_string(), (arity, next_id + 1));
Ok(row_id)
}
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
let exists: bool = self
.live()
.query_row("SELECT 1 FROM __meta WHERE name = ?", params![name], |_| {
Ok(())
})
.optional()
.map_err(backend)?
.is_some();
if !exists {
return Err(StorageError::RelationNotFound(name.to_string()));
}
self.live()
.execute(
"DELETE FROM __rows WHERE rel = ? AND row_id = ?",
params![name, id.as_bytes()],
)
.map_err(backend)?;
Ok(())
}
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError> {
let mut this = self;
let Some(txn) = this.txn.take() else {
unreachable!("transaction was already committed")
};
for (name, (arity, next_id)) in this.next_ids.drain() {
txn.execute(
"UPDATE __meta SET arity = ?, next_id = ? WHERE name = ?",
params![arity, next_id, name],
)
.map_err(backend)?;
}
txn.commit().map_err(backend)?;
Ok(CommittedTx::empty())
}
}
#[cfg(test)]
mod tests {
use super::SqliteStorage;
use crate::value::Value;
use crate::{Storage, StorageError};
fn i(x: i64) -> Value {
Value::Int(x)
}
fn open_memory() -> Result<SqliteStorage, StorageError> {
SqliteStorage::open(":memory:")
}
#[test]
fn create_insert_scan_roundtrip() -> Result<(), StorageError> {
let mut storage = open_memory()?;
storage.create_relation("edge", 2)?;
let id0 = storage.insert("edge", vec![i(1), i(2)])?;
let id1 = storage.insert("edge", vec![i(2), i(3)])?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(id0, vec![i(1), i(2)]), (id1, vec![i(2), i(3)])]);
assert_eq!(storage.arity("edge")?, 2);
Ok(())
}
#[test]
fn batched_inserts_share_one_commit() -> Result<(), StorageError> {
let mut storage = open_memory()?;
storage.create_relation("edge", 2)?;
let (a, b) = {
let mut tx = storage.transaction()?;
let a = tx.insert("edge", vec![i(1), i(2)])?;
let b = tx.insert("edge", vec![i(3), i(4)])?;
tx.commit()?;
(a, b)
};
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(a, vec![i(1), i(2)]), (b, vec![i(3), i(4)])]);
Ok(())
}
#[test]
fn dropped_transaction_is_rolled_back() -> Result<(), StorageError> {
let mut storage = open_memory()?;
storage.create_relation("edge", 2)?;
{
let mut tx = storage.transaction()?;
tx.insert("edge", vec![i(1), i(2)])?;
}
assert!(storage.scan("edge")?.is_empty());
Ok(())
}
#[test]
fn delete_removes_row() -> Result<(), StorageError> {
let mut storage = open_memory()?;
storage.create_relation("edge", 1)?;
let a = storage.insert("edge", vec![i(1)])?;
let b = storage.insert("edge", vec![i(2)])?;
storage.delete("edge", &a)?;
let rows = storage.scan("edge")?;
assert_eq!(rows, vec![(b, vec![i(2)])]);
// Idempotent on missing.
storage.delete("edge", &a)?;
Ok(())
}
#[test]
fn duplicate_create_returns_err() -> Result<(), StorageError> {
let mut storage = open_memory()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.create_relation("edge", 2),
Err(StorageError::RelationExists(_))
));
Ok(())
}
#[test]
fn insert_wrong_arity_returns_err() -> Result<(), StorageError> {
let mut storage = open_memory()?;
storage.create_relation("edge", 2)?;
assert!(matches!(
storage.insert("edge", vec![i(1)]),
Err(StorageError::ArityMismatch {
expected: 2,
got: 1,
})
));
Ok(())
}
}

View File

@ -17,6 +17,7 @@
//! |--------|---------------|--------------------------------------|
//! | `0x00` | `Value::Int` | `i64 LE` (8 bytes) |
//! | `0x01` | `Value::Str` | `[len: u32 LE] [bytes]` |
//! | `0x02` | `Value::Id` | `[len: u32 LE] [bytes]` |
//!
//! ## Row Key Format
//!
@ -28,6 +29,7 @@
//!
//! Per-relation metadata is `[arity: u32 LE] [next_id: u64 LE]` = 12 bytes.
use crate::id::RowId;
use crate::value::Value;
/// Errors raised by [`decode_row`] and [`decode_meta`].
@ -81,6 +83,14 @@ pub fn encode_row(row: &[Value]) -> Vec<u8> {
);
out.extend_from_slice(bytes);
}
Value::Id(id) => {
out.push(0x02);
let bytes = id.as_bytes();
out.extend_from_slice(
&u32::try_from(bytes.len()).unwrap_or(u32::MAX).to_le_bytes(),
);
out.extend_from_slice(bytes);
}
}
}
out
@ -121,6 +131,18 @@ fn read_value(bytes: &mut &[u8]) -> Result<Value, CodecError> {
.to_string();
Ok(Value::Str(s))
}
0x02 => {
let len = read_u32(bytes)? as usize;
if bytes.len() < len {
return Err(CodecError::LengthOverrun {
declared: len,
available: bytes.len(),
});
}
let (head, tail) = bytes.split_at(len);
*bytes = tail;
Ok(Value::Id(RowId::new(head)))
}
other => Err(CodecError::UnknownTag(other)),
}
}

47
crates/storage/src/id.rs Normal file
View File

@ -0,0 +1,47 @@
//! Opaque row identifiers.
//!
//! Each backend assigns its own bytes for a [`RowId`]; the trait treats them
//! opaquely. The in-memory backend and the KV backends use a big-endian `u64`
//! row counter; the geomerge backend encodes its `(CommitHash, counter)`
//! pair. Callers do not interpret the bytes: they hand a `RowId` back to the
//! same backend to reference an existing row.
//!
//! Storage is inline up to 36 bytes (the geomerge `(CommitHash, counter)`
//! width), which covers every encoding the workspace produces today. Wider
//! row-id encodings spill to the heap automatically via [`SmallVec`].
use smallvec::SmallVec;
const INLINE_BYTES: usize = 36;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RowId(SmallVec<[u8; INLINE_BYTES]>);
impl RowId {
/// Build a row id by copying the given bytes into inline storage when they
/// fit (≤ 36 bytes) or onto the heap when they don't.
pub fn new(bytes: impl AsRef<[u8]>) -> Self {
Self(SmallVec::from_slice(bytes.as_ref()))
}
/// View the underlying bytes.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Display for RowId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for byte in &self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
impl From<u64> for RowId {
fn from(value: u64) -> Self {
Self(SmallVec::from_slice(&value.to_be_bytes()))
}
}

323
crates/storage/src/lib.rs Normal file
View File

@ -0,0 +1,323 @@
//! Storage layer for the query-plan playground.
//!
//! This is the foundational crate of the workspace. It owns the [`Value`] cell
//! type and the [`Table`] container, defines the [`Storage`] trait, and ships
//! adapters for several backends behind Cargo features. Higher-level crates
//! such as `query-ops` depend on this crate for both the types and the trait.
//!
//! The trait covers relation creation, lazy and materialized scans, point
//! filters by column value, atomic transactions with batched inserts and
//! deletes, and a [`CommittedTx`] mechanism for resolving pending row IDs.
//! Delta streams and indexes are not modeled, and will be added when an
//! experiment demands them.
//!
//! ## Backends
//!
//! [`MemoryStorage`] is always available. Other backends are gated behind
//! Cargo features so users only pay for what they need:
//!
//! - `lmdb`: LMDB via the `heed` crate
//! - `redb`: pure-Rust embedded KV
//! - `fjall`: pure-Rust LSM-tree
//! - `sqlite`: `SQLite` via the `rusqlite` crate (bundled libsqlite3)
//! - `geomerge`: the workspace's `geomerge` crate
use crate::id::RowId;
use crate::table::Table;
use crate::value::Value;
pub mod adapters;
pub mod id;
pub mod table;
pub mod value;
#[allow(dead_code)] // helpers are used by feature-gated kv adapters
pub(crate) mod codec;
pub use adapters::memory::MemoryStorage;
pub use codec::CodecError;
/// Errors returned by a [`Storage`] backend.
///
/// Backend-specific failures (LMDB transaction aborts, fjall I/O errors,
/// `rusqlite` errors, etc.) are wrapped in [`StorageError::Backend`].
#[derive(Debug)]
pub enum StorageError {
/// No relation with the given name exists in this backend.
RelationNotFound(String),
/// A relation with the given name already exists.
RelationExists(String),
/// A row was offered with the wrong number of columns.
ArityMismatch { expected: usize, got: usize },
/// A backend-defined validation rule rejected the operation, for example
/// a `geomerge` law violation.
Validation(String),
/// A row decoded from storage was malformed.
Decode(codec::CodecError),
/// The backend does not implement the requested operation, for example
/// `geomerge`'s append-only commit log rejecting row deletion. The static
/// string describes the unsupported operation.
Unsupported(&'static str),
/// A backend-specific error wrapped for transport across the trait.
Backend(Box<dyn std::error::Error + Send + Sync>),
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RelationNotFound(name) => write!(f, "relation not found: {name}"),
Self::RelationExists(name) => write!(f, "relation already exists: {name}"),
Self::ArityMismatch { expected, got } => {
write!(f, "arity mismatch: expected {expected}, got {got}")
}
Self::Validation(msg) => write!(f, "validation failed: {msg}"),
Self::Decode(err) => write!(f, "decode error: {err}"),
Self::Unsupported(op) => write!(f, "operation not supported by this backend: {op}"),
Self::Backend(err) => write!(f, "backend error: {err}"),
}
}
}
impl std::error::Error for StorageError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Backend(err) => Some(err.as_ref()),
Self::Decode(err) => Some(err),
_ => None,
}
}
}
impl From<codec::CodecError> for StorageError {
fn from(err: codec::CodecError) -> Self {
Self::Decode(err)
}
}
/// Wrap any `Error + Send + Sync` as [`StorageError::Backend`]. Used by
/// adapters in `.map_err(backend)?` chains.
pub(crate) fn backend<E: std::error::Error + Send + Sync + 'static>(err: E) -> StorageError {
StorageError::Backend(Box::new(err))
}
/// Lazily-yielded sequence of `(RowId, row)` pairs returned by
/// [`Storage::scan_iter`]. Each item is a `Result` so backends can surface
/// errors mid-iteration.
pub type RowStream<'a> = Box<dyn Iterator<Item = Result<(RowId, Vec<Value>), StorageError>> + 'a>;
/// Backend-agnostic interface for storing and retrieving rows.
///
/// Each relation has a fixed name, a fixed arity (row width), and an ordered
/// collection of rows whose cells are [`Value`]s. Concrete implementations
/// include [`MemoryStorage`] in this crate plus the feature-gated backends.
pub trait Storage {
/// Create a new relation with the given name and arity.
///
/// # Errors
/// Returns [`StorageError::RelationExists`] if a relation with the given
/// name already exists.
fn create_relation(&mut self, name: &str, arity: usize) -> Result<(), StorageError>;
/// Return the arity of the given relation.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists.
fn arity(&self, name: &str) -> Result<usize, StorageError>;
/// Scan all rows of the given relation in storage order, paired with each
/// row's backend-assigned [`RowId`]. Yields rows lazily; backends that can
/// stream from disk (memory, fjall) do so; backends that must materialize
/// (sqlite, redb, lmdb, geomerge) build a `Vec` internally and return its
/// iterator. The returned iterator borrows from `self` for `'a`.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists.
/// Iteration may also yield per-row errors when a backend fails mid-scan.
fn scan_iter<'a>(&'a self, name: &str) -> Result<RowStream<'a>, StorageError>;
/// Materialize a full scan of the given relation. The default
/// implementation collects [`Storage::scan_iter`]; backends with a
/// cheaper batch-read path may override.
///
/// # Errors
/// Returns the same errors as [`Storage::scan_iter`].
fn scan(&self, name: &str) -> Result<Vec<(RowId, Vec<Value>)>, StorageError> {
self.scan_iter(name)?.collect()
}
/// Scan rows of the given relation where the cell at `column` equals
/// `value`. The default implementation reads from [`Storage::scan_iter`]
/// and filters in memory, giving the query planner a single target to
/// push filters down to even when backends don't yet have indexes;
/// backends that gain column indexes can override for efficient lookup.
///
/// Rows whose row width is `<= column` are silently skipped (no match).
///
/// # Errors
/// Returns the same errors as [`Storage::scan_iter`].
fn scan_where<'a>(
&'a self,
name: &str,
column: usize,
value: &Value,
) -> Result<RowStream<'a>, StorageError> {
let target = value.clone();
let iter = self.scan_iter(name)?.filter_map(move |entry| match entry {
Ok((id, row)) => {
if row.get(column) == Some(&target) {
Some(Ok((id, row)))
} else {
None
}
}
Err(err) => Some(Err(err)),
});
Ok(Box::new(iter))
}
/// Begin a transaction. Inserts within the returned transaction are
/// committed atomically when [`Transaction::commit`] is called; dropping
/// the transaction without committing rolls back any pending inserts.
///
/// Backends with native transactions (LMDB, redb, `SQLite`, geomerge) wrap
/// their real write transactions; backends without (memory, fjall) buffer
/// inserts in memory and apply them on commit.
///
/// # Errors
/// Returns [`StorageError::Backend`] if the backend can't begin a tx.
fn transaction<'a>(&'a mut self) -> Result<Box<dyn Transaction + 'a>, StorageError>;
/// Append a row to the given relation, returning the new row's
/// backend-assigned [`RowId`]. The default implementation opens a fresh
/// transaction, inserts, and commits; for chains of related inserts that
/// must satisfy backend laws together (e.g. geomerge totality and
/// foreign-key laws), call [`Storage::transaction`] explicitly.
///
/// # Errors
/// Returns the same errors as [`Transaction::insert`] and
/// [`Transaction::commit`].
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError> {
let mut tx = self.transaction()?;
let pending = tx.insert(name, row)?;
let committed = tx.commit()?;
Ok(committed.resolve(&pending))
}
/// Remove the row with the given id from the given relation. The default
/// implementation opens a fresh transaction, deletes, and commits;
/// deleting a row that doesn't exist is a no-op.
///
/// # Errors
/// Returns the same errors as [`Transaction::delete`] and
/// [`Transaction::commit`]. Backends that don't support deletion
/// return [`StorageError::Backend`].
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
let mut tx = self.transaction()?;
tx.delete(name, id)?;
let _ = tx.commit()?;
Ok(())
}
}
/// A transaction over a [`Storage`] backend. Holds pending inserts; commits
/// them atomically on [`Transaction::commit`]; rolls back on drop.
///
/// `RowId`s returned by [`Transaction::insert`] are valid for foreign-key
/// references in subsequent inserts within the same transaction. After commit,
/// callers can resolve those pending ids to their post-commit form via the
/// returned [`CommittedTx`].
pub trait Transaction {
/// Append a row to the given relation as part of this transaction.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists,
/// [`StorageError::ArityMismatch`] if the row's length differs from the
/// declared arity, or [`StorageError::Validation`] / [`StorageError::Backend`]
/// for backend-specific rejections.
fn insert(&mut self, name: &str, row: Vec<Value>) -> Result<RowId, StorageError>;
/// Remove the row with the given id from the given relation as part of
/// this transaction. Idempotent: deleting an id that doesn't exist is
/// not an error.
///
/// The default implementation returns [`StorageError::Unsupported`];
/// backends that allow deletion override it. Geomerge, in particular, has
/// an append-only commit log and does not implement this.
///
/// # Errors
/// Returns [`StorageError::RelationNotFound`] if no such relation exists,
/// or [`StorageError::Unsupported`] if the backend doesn't support
/// deletion.
fn delete(&mut self, name: &str, id: &RowId) -> Result<(), StorageError> {
let _ = (name, id);
Err(StorageError::Unsupported("row deletion"))
}
/// Commit all pending inserts atomically. Returns a [`CommittedTx`] that
/// can resolve pending [`RowId`]s (those returned by
/// [`Transaction::insert`]) to their post-commit form.
///
/// For KV backends, pending ids are stable across commit; the returned
/// `CommittedTx` is empty and [`CommittedTx::resolve`] returns the same
/// id. For the geomerge backend, the returned map carries each pending
/// 4-byte counter mapped to the post-commit 36-byte
/// `(CommitHash, counter)` form.
///
/// # Errors
/// Returns [`StorageError::Validation`] if a backend law (e.g. a geomerge
/// totality or foreign-key law) is violated by the committed state, or
/// [`StorageError::Backend`] for other backend failures.
fn commit(self: Box<Self>) -> Result<CommittedTx, StorageError>;
}
/// The result of a successful [`Transaction::commit`]. Carries the
/// pending-to-real [`RowId`] mapping for backends that need it (currently
/// only `geomerge`); is empty for backends where pending ids are stable.
#[derive(Debug, Default, Clone)]
pub struct CommittedTx {
resolutions: std::collections::HashMap<RowId, RowId>,
}
impl CommittedTx {
/// Empty resolution table. Backends where pending ids are already stable
/// return this from `commit`.
#[must_use]
pub fn empty() -> Self {
Self::default()
}
/// Construct a resolution table from explicit pending-to-real mappings.
/// Crate-internal: only the `geomerge` adapter builds non-empty
/// resolution tables.
#[must_use]
pub(crate) fn from_mappings(resolutions: std::collections::HashMap<RowId, RowId>) -> Self {
Self { resolutions }
}
/// Resolve a [`RowId`] returned by [`Transaction::insert`] to its
/// post-commit form. If no explicit mapping is recorded, the pending id
/// is returned unchanged (i.e. KV backends where pending == real).
#[must_use]
pub fn resolve(&self, pending: &RowId) -> RowId {
self.resolutions
.get(pending)
.cloned()
.unwrap_or_else(|| pending.clone())
}
}
/// Materialize a relation from a [`Storage`] backend as a [`Table`] that
/// query-language operators can consume. Row IDs returned by [`Storage::scan`]
/// are dropped; the resulting [`Table`] carries only cell values.
///
/// # Errors
/// Returns any error produced by [`Storage::arity`] or [`Storage::scan`].
pub fn scan_as_table(storage: &dyn Storage, name: &str) -> Result<Table, StorageError> {
let arity = storage.arity(name)?;
let rows = storage
.scan(name)?
.into_iter()
.map(|(_, row)| row)
.collect();
Ok(Table::from_rows(arity, rows))
}

View File

@ -1,7 +1,10 @@
//! Cell values shared by tables and binding relations.
use crate::id::RowId;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Value {
Int(i64),
Str(String),
Id(RowId),
}