lora-ruby
Ruby bindings for the Lora graph engine.
Ships a native extension built with Magnus
on top of rb-sys so the Rust
engine runs in-process — no separate server, no socket hop.
Status: prototype / feasibility check. Published source gem on RubyGems; precompiled platform gems are built for the supported targets (see "Release" below).
Install
gem install lora-ruby
# or in a Gemfile
gem "lora-ruby"
require "lora_ruby" loads the native extension from
lib/lora_ruby/lora_ruby.{so,bundle,dll}. If a precompiled gem for
your platform exists on RubyGems, the install is a direct download; if
not, the source gem is built locally with cargo and a stable Rust
toolchain (1.87+).
Usage
require "lora_ruby"
db = LoraRuby::Database.create
db.execute("CREATE (:Person {name: $n, age: $a})", { n: "Alice", a: 30 })
result = db.execute("MATCH (n:Person) RETURN n")
result["rows"].each do |row|
n = row["n"]
puts n["properties"]["name"] if LoraRuby.node?(n)
end
Initialization rule:
scratch = LoraRuby::Database.create # in-memory
persistent = LoraRuby::Database.create("app", {"database_dir": "./data"}) # persistent: ./data/app.loradb
If you want persistence, pass a database name and database_dir to
LoraRuby::Database.create(...) or LoraRuby::Database.new(...).
Params
execute accepts a second argument — either nil or a Hash keyed by
parameter name (String or Symbol). Values can be any of:
nil,true,false,Integer,Float,String,Symbol(stringified)Arrayof the above (recursive)Hashkeyed byString/Symbolwith the above values (recursive)- Tagged temporal/spatial Hashes produced by the constructors below
Module shape — why LoraRuby::Database?
Three reasonable options were considered:
LoraRuby::Database— matches the gem filename; scope is obvious.LoraDB::Database— matches the brand (loradb.com).Lora::Database— shortest, but collides with arbitrary "lora" apps.
We went with LoraRuby::Database for symmetry with the Python
binding's lora_python.Database and because it mirrors the
require "lora_ruby" path exactly. The gem name on RubyGems is
lora-ruby (hyphen); the Ruby constant follows Rubocop convention
(CamelCase, no hyphen).
Public API
LoraRuby::Database.create(database_name = nil, = nil) # -> Database
LoraRuby::Database.new(database_name = nil, = nil) # -> Database
LoraRuby::Database.open_wal(wal_dir, = nil) # -> Database
db.execute(query, params = nil) # -> { "columns" => [...], "rows" => [...] }
db.explain(query, params = nil) # -> plan Hash; never executes
db.profile(query, params = nil) # -> { "plan" => ..., "metrics" => ... }; PROFILE executes writes
db.clear # -> nil
db.node_count # -> Integer
db.relationship_count # -> Integer
LoraRuby::VERSION # gem version
Result shape:
{
"columns" => ["name"],
"rows" => [{ "name" => "Alice" }],
}
Hash keys on the output are always strings, matching the lora-node,
lora-wasm, and lora-python bindings. Input Hashes accept either
symbol or string keys — both work for param names and for tagged
constructor Hashes like point/date/...
Explain & Profile
db.explain and db.profile are first-class methods alongside
db.execute. They are intentionally separate calls, not a flag on
execute, so plan inspection and runtime metrics must be requested
explicitly.
plan = db.explain(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
{ "name" => "Alice" }
)
plan["shape"] # => "readOnly"
plan["tree"]["operator"]
profile = db.profile(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
{ "name" => "Alice" }
)
profile["metrics"]["total_elapsed_ns"]
profile["metrics"]["per_operator"] # per-step inclusive timing
explain never invokes the executor — calling it on a mutating query
(CREATE, MERGE, SET, DELETE, REMOVE) leaves the graph
untouched.
profileexecutes the query for real. Mutating queries are persisted exactly as inexecute. Useexplainto inspect a mutating plan without running it.
Typed value model
Identical contract to the other bindings:
| Ruby shape | Lora value |
|---|---|
nil, true/false, Integer, Float, String |
scalars |
Array, Hash |
collections |
{"kind" => "node", "id", "labels", "properties"} |
node |
{"kind" => "relationship", "id", …} |
relationship |
{"kind" => "path", "nodes" => [...], "rels" => [...]} |
path |
{"kind" => "date", "iso" => "YYYY-MM-DD"} (and time, …) |
temporal |
| point Hashes (below) | point |
Points are returned as Hashes keyed on their CRS:
| SRID | Hash |
|---|---|
| 7203 | {"kind"=>"point","srid"=>7203,"crs"=>"cartesian","x","y"} |
| 9157 | {"kind"=>"point","srid"=>9157,"crs"=>"cartesian-3D","x","y","z"} |
| 4326 | {"kind"=>"point","srid"=>4326,"crs"=>"WGS-84-2D","x","y","longitude","latitude"} |
| 4979 | {"kind"=>"point","srid"=>4979,"crs"=>"WGS-84-3D","x","y","z","longitude","latitude","height"} |
Constructors and guards
Re-exported on both LoraRuby and LoraRuby::Types:
- Constructors:
date,time,localtime,datetime,localdatetime,duration,cartesian,cartesian_3d,wgs84,wgs84_3d. - Guards:
node?,relationship?,path?,point?,temporal?.
db.execute(
"CREATE (:Event {on: $d, at: $c})",
{ d: LoraRuby.date("2025-03-14"), c: LoraRuby.cartesian(1.5, 2.5) },
)
Errors
LoraRuby::Error— base class (extendsStandardError).LoraRuby::QueryError— parse / analyze / execute failure.LoraRuby::InvalidParamsError— a parameter value couldn't be mapped.
Persistence
LoraRuby::Database.create("app", {"database_dir": "./data"}) and
LoraRuby::Database.new("app", { database_dir: "./data" }) open or create
an archive-backed persistent database at ./data/app.loradb. Reopening the same path
replays committed writes before returning the handle.
Call db.close before reopening the same archive inside one
process.
For explicit WAL directories with managed snapshots, use open_wal:
db = LoraRuby::Database.open_wal(
"./data/wal",
snapshot_dir: "./data/snapshots",
snapshot_every_commits: 1000,
snapshot_keep_old: 2,
)
snapshot_options accepts the same compression/encryption options as
save_snapshot.
Concurrency (GVL release)
Database#execute calls rb_thread_call_without_gvl, so other Ruby
threads run while the engine is busy. Auto-commit reads can overlap on engine
snapshots; write commits and explicit read-write transactions serialize.
The engine has no cancellation hook, so we pass a NULL unblock
function. A thread interrupted mid-query (Thread#kill) will observe
the interrupt after the current query finishes. Keep queries short
if you rely on cooperative cancellation.
Local development
cd crates/bindings/lora-ruby
bundle install
bundle exec rake compile # cargo build → lib/lora_ruby/lora_ruby.<ext>
bundle exec rake test # minitest
bundle exec rake build # pkg/lora-ruby-<version>.gem
rake compile drives cargo through rb_sys/extensiontask; it is
what gem install runs on end-user machines that don't have a
precompiled platform gem.
Architecture
lora-database (Rust, embedded)
└── crates/bindings/lora-ruby/ (gem root + cargo crate)
├── Cargo.toml Rust workspace member
├── extconf.rb rb-sys / mkmf entry point
├── src/lib.rs <- Magnus / rb-sys bindings
└── lib/lora_ruby/
├── lora_ruby.<ext> (native, built by rake compile)
├── types.rb tagged-dict constructors + guards
└── version.rb gem version
rb-sys' convention keeps Cargo.toml and extconf.rb side by side so
the cargo manifest directory IS the gem root. That makes the crate
a first-class Cargo workspace member (shared Cargo.lock,
target/, cargo check --workspace coverage) without a nested
ext/<name>/ directory.
Release
Source gem is always built. Precompiled platform gems are emitted via
rb_sys/cross for {x86_64,aarch64}-linux, {x86_64,arm64}-darwin, and
x64-mingw-ucrt. See .github/workflows/packages-release.yml (Ruby
section) and RELEASING.md.