lora-ruby 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Cargo.toml +46 -0
- data/LICENSE +87 -0
- data/README.md +190 -0
- data/build.rs +12 -0
- data/extconf.rb +17 -0
- data/lib/lora_ruby/types.rb +140 -0
- data/lib/lora_ruby/version.rb +14 -0
- data/lib/lora_ruby.rb +46 -0
- data/src/lib.rs +742 -0
- metadata +115 -0
data/src/lib.rs
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
#![deny(clippy::all)]
|
|
2
|
+
|
|
3
|
+
//! Magnus + rb-sys bindings for the Lora graph database.
|
|
4
|
+
//!
|
|
5
|
+
//! The Rust engine is synchronous. We expose a single `LoraRuby::Database`
|
|
6
|
+
//! class and release Ruby's GVL for the duration of each query via
|
|
7
|
+
//! `rb_thread_call_without_gvl` so other Ruby threads can progress while
|
|
8
|
+
//! the engine runs. Concurrent calls against the same `Database`
|
|
9
|
+
//! serialise on an internal mutex but do not hold the GVL.
|
|
10
|
+
//!
|
|
11
|
+
//! Value conversion follows the shared `LoraValue` contract used by
|
|
12
|
+
//! `lora-node`, `lora-wasm`, and `lora-python`: primitives pass through
|
|
13
|
+
//! as Ruby natives; graph, temporal, and spatial values are returned as
|
|
14
|
+
//! tagged `Hash`es (string keys) with a `"kind"` discriminator.
|
|
15
|
+
|
|
16
|
+
use std::collections::BTreeMap;
|
|
17
|
+
use std::ffi::c_void;
|
|
18
|
+
use std::mem::MaybeUninit;
|
|
19
|
+
use std::sync::{Arc, Mutex};
|
|
20
|
+
|
|
21
|
+
use magnus::{
|
|
22
|
+
function, method, prelude::*, r_hash::ForEach, value::ReprValue, Error as MagnusError,
|
|
23
|
+
ExceptionClass, Float, Integer, RArray, RHash, RModule, RString, Ruby, Symbol, Value,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
use lora_database::{
|
|
27
|
+
Database as InnerDatabase, ExecuteOptions, InMemoryGraph, LoraValue, QueryResult, ResultFormat,
|
|
28
|
+
};
|
|
29
|
+
use lora_store::{
|
|
30
|
+
GraphStorage, LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime,
|
|
31
|
+
LoraPoint, LoraTime, LoraVector, RawCoordinate, VectorCoordinateType, VectorValues,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Module / exception registration
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/// rb-sys init hook.
|
|
39
|
+
///
|
|
40
|
+
/// `extconf.rb` (at the gem/crate root) calls
|
|
41
|
+
/// `create_rust_makefile("lora_ruby/lora_ruby")`, which names the
|
|
42
|
+
/// resulting shared object `lora_ruby.{so,bundle,dll}`. Ruby then
|
|
43
|
+
/// calls `Init_lora_ruby` when the extension is loaded;
|
|
44
|
+
/// `magnus::init` wraps that C-ABI entry point for us.
|
|
45
|
+
#[magnus::init]
|
|
46
|
+
fn init(ruby: &Ruby) -> Result<(), MagnusError> {
|
|
47
|
+
let lora_ruby = ruby.define_module("LoraRuby")?;
|
|
48
|
+
|
|
49
|
+
// Error hierarchy — mirrors the Python binding's LoraError /
|
|
50
|
+
// LoraQueryError / InvalidParamsError tree, but follows Ruby naming
|
|
51
|
+
// (`Error` as the base class, subclasses for each concrete case).
|
|
52
|
+
// `Module::define_class` wants an `RClass`; `ExceptionClass::as_r_class`
|
|
53
|
+
// strips the exception-typed wrapper while keeping the underlying
|
|
54
|
+
// class intact. The subclasses are later retrieved as
|
|
55
|
+
// `ExceptionClass` via `const_get`, which is sound because they
|
|
56
|
+
// still descend from `Exception` on the Ruby side.
|
|
57
|
+
let standard_error = ruby.exception_standard_error().as_r_class();
|
|
58
|
+
let error = lora_ruby.define_class("Error", standard_error)?;
|
|
59
|
+
lora_ruby.define_class("QueryError", error)?;
|
|
60
|
+
lora_ruby.define_class("InvalidParamsError", error)?;
|
|
61
|
+
|
|
62
|
+
let database = lora_ruby.define_class("Database", ruby.class_object())?;
|
|
63
|
+
database.define_singleton_method("create", function!(database_create, 0))?;
|
|
64
|
+
database.define_singleton_method("new", function!(database_new, 0))?;
|
|
65
|
+
database.define_method("execute", method!(database_execute, -1))?;
|
|
66
|
+
database.define_method("clear", method!(database_clear, 0))?;
|
|
67
|
+
database.define_method("node_count", method!(database_node_count, 0))?;
|
|
68
|
+
database.define_method(
|
|
69
|
+
"relationship_count",
|
|
70
|
+
method!(database_relationship_count, 0),
|
|
71
|
+
)?;
|
|
72
|
+
database.define_method("inspect", method!(database_inspect, 0))?;
|
|
73
|
+
database.define_method("to_s", method!(database_inspect, 0))?;
|
|
74
|
+
|
|
75
|
+
// `LoraRuby::VERSION` is owned by `lib/lora_ruby/version.rb` so the
|
|
76
|
+
// gem can expose a version before the native extension compiles
|
|
77
|
+
// (during `gem build` / `bundle install`). Defining it here too
|
|
78
|
+
// would trigger a "already initialized constant" warning on load.
|
|
79
|
+
|
|
80
|
+
Ok(())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Error lookups
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
fn lora_module(ruby: &Ruby) -> RModule {
|
|
88
|
+
ruby.class_object()
|
|
89
|
+
.const_get::<_, RModule>("LoraRuby")
|
|
90
|
+
.expect("LoraRuby module is defined by `init` before any method runs")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fn lora_error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
|
|
94
|
+
// `const_get::<_, ExceptionClass>` converts the stored RClass into
|
|
95
|
+
// an ExceptionClass — this is the sound path, because our subclasses
|
|
96
|
+
// of StandardError retain the exception-class trait on the Ruby
|
|
97
|
+
// side even though `define_class` typed them as RClass.
|
|
98
|
+
lora_module(ruby)
|
|
99
|
+
.const_get::<_, ExceptionClass>(name)
|
|
100
|
+
.unwrap_or_else(|_| ruby.exception_standard_error())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn query_error(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
104
|
+
MagnusError::new(lora_error_class(ruby, "QueryError"), msg.into())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn invalid_params(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
108
|
+
MagnusError::new(lora_error_class(ruby, "InvalidParamsError"), msg.into())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Database
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/// In-memory Lora graph database handle exposed to Ruby.
|
|
116
|
+
///
|
|
117
|
+
/// `Arc<Mutex<InMemoryGraph>>` gives us cheap cloning so the mutex guard
|
|
118
|
+
/// can be sent across the GVL-release boundary without borrowing any Ruby
|
|
119
|
+
/// state.
|
|
120
|
+
#[magnus::wrap(class = "LoraRuby::Database", free_immediately, size)]
|
|
121
|
+
struct Database {
|
|
122
|
+
store: Arc<Mutex<InMemoryGraph>>,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
impl Database {
|
|
126
|
+
fn empty() -> Self {
|
|
127
|
+
Self {
|
|
128
|
+
store: Arc::new(Mutex::new(InMemoryGraph::new())),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Constructors — we expose `Database.create` and `Database.new` as
|
|
134
|
+
// singletons so callers can use whichever idiom they prefer; both are
|
|
135
|
+
// cost-equivalent.
|
|
136
|
+
fn database_new() -> Database {
|
|
137
|
+
Database::empty()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn database_create() -> Database {
|
|
141
|
+
Database::empty()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn database_clear(rb_self: &Database) {
|
|
145
|
+
let mut guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
|
|
146
|
+
*guard = InMemoryGraph::new();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn database_node_count(rb_self: &Database) -> u64 {
|
|
150
|
+
let guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
|
|
151
|
+
guard.node_count() as u64
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fn database_relationship_count(rb_self: &Database) -> u64 {
|
|
155
|
+
let guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
|
|
156
|
+
guard.relationship_count() as u64
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn database_inspect(rb_self: &Database) -> String {
|
|
160
|
+
let guard = rb_self.store.lock().unwrap_or_else(|p| p.into_inner());
|
|
161
|
+
format!(
|
|
162
|
+
"#<LoraRuby::Database nodes={} relationships={}>",
|
|
163
|
+
guard.node_count(),
|
|
164
|
+
guard.relationship_count(),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// `execute(query, params = nil)` — `-1` arity so `params` is optional and
|
|
169
|
+
/// we can distinguish "not passed" from `nil`/`{}` (both map to empty
|
|
170
|
+
/// params). Everything that touches Ruby values happens under the GVL;
|
|
171
|
+
/// only the pure-Rust engine call is run GVL-released.
|
|
172
|
+
fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<RHash, MagnusError> {
|
|
173
|
+
let (query, params_value): (String, Option<Value>) = match args.len() {
|
|
174
|
+
1 => {
|
|
175
|
+
let q = RString::try_convert(args[0])?.to_string()?;
|
|
176
|
+
(q, None)
|
|
177
|
+
}
|
|
178
|
+
2 => {
|
|
179
|
+
let q = RString::try_convert(args[0])?.to_string()?;
|
|
180
|
+
let p = if args[1].is_nil() {
|
|
181
|
+
None
|
|
182
|
+
} else {
|
|
183
|
+
Some(args[1])
|
|
184
|
+
};
|
|
185
|
+
(q, p)
|
|
186
|
+
}
|
|
187
|
+
n => {
|
|
188
|
+
return Err(MagnusError::new(
|
|
189
|
+
ruby.exception_arg_error(),
|
|
190
|
+
format!("wrong number of arguments (given {n}, expected 1..2)"),
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Parse params while we still hold the GVL — touching Ruby `RHash` /
|
|
196
|
+
// `RArray` from a GVL-released region is undefined behaviour.
|
|
197
|
+
let params_map = match params_value {
|
|
198
|
+
Some(v) => ruby_value_to_params(ruby, v)?,
|
|
199
|
+
None => BTreeMap::new(),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Run the engine with the GVL released. Everything inside the closure
|
|
203
|
+
// is pure Rust — no Ruby values cross the boundary — which keeps this
|
|
204
|
+
// sound.
|
|
205
|
+
let store = Arc::clone(&rb_self.store);
|
|
206
|
+
let exec_result = without_gvl(move || {
|
|
207
|
+
let db = InnerDatabase::new(store);
|
|
208
|
+
let options = ExecuteOptions {
|
|
209
|
+
format: ResultFormat::RowArrays,
|
|
210
|
+
};
|
|
211
|
+
db.execute_with_params(&query, Some(options), params_map)
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
let row_arrays = match exec_result {
|
|
215
|
+
Ok(QueryResult::RowArrays(r)) => r,
|
|
216
|
+
Ok(_) => return Err(query_error(ruby, "expected RowArrays result")),
|
|
217
|
+
Err(e) => return Err(query_error(ruby, format!("{e}"))),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
let out = ruby.hash_new();
|
|
221
|
+
let columns = ruby.ary_new();
|
|
222
|
+
for c in &row_arrays.columns {
|
|
223
|
+
columns.push(ruby.str_new(c))?;
|
|
224
|
+
}
|
|
225
|
+
out.aset(ruby.str_new("columns"), columns)?;
|
|
226
|
+
|
|
227
|
+
let rows = ruby.ary_new();
|
|
228
|
+
for row in &row_arrays.rows {
|
|
229
|
+
let row_hash = ruby.hash_new();
|
|
230
|
+
for (col, val) in row_arrays.columns.iter().zip(row.iter()) {
|
|
231
|
+
row_hash.aset(ruby.str_new(col), lora_value_to_ruby(ruby, val)?)?;
|
|
232
|
+
}
|
|
233
|
+
rows.push(row_hash)?;
|
|
234
|
+
}
|
|
235
|
+
out.aset(ruby.str_new("rows"), rows)?;
|
|
236
|
+
Ok(out)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// LoraValue → Ruby
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
fn lora_value_to_ruby(ruby: &Ruby, value: &LoraValue) -> Result<Value, MagnusError> {
|
|
244
|
+
match value {
|
|
245
|
+
LoraValue::Null => Ok(ruby.qnil().as_value()),
|
|
246
|
+
LoraValue::Bool(b) => Ok(if *b {
|
|
247
|
+
ruby.qtrue().as_value()
|
|
248
|
+
} else {
|
|
249
|
+
ruby.qfalse().as_value()
|
|
250
|
+
}),
|
|
251
|
+
LoraValue::Int(i) => Ok(ruby.integer_from_i64(*i).as_value()),
|
|
252
|
+
LoraValue::Float(f) => Ok(ruby.float_from_f64(*f).as_value()),
|
|
253
|
+
LoraValue::String(s) => Ok(ruby.str_new(s).as_value()),
|
|
254
|
+
LoraValue::List(items) => {
|
|
255
|
+
let arr = ruby.ary_new();
|
|
256
|
+
for item in items {
|
|
257
|
+
arr.push(lora_value_to_ruby(ruby, item)?)?;
|
|
258
|
+
}
|
|
259
|
+
Ok(arr.as_value())
|
|
260
|
+
}
|
|
261
|
+
LoraValue::Map(m) => {
|
|
262
|
+
let h = ruby.hash_new();
|
|
263
|
+
for (k, v) in m {
|
|
264
|
+
h.aset(ruby.str_new(k), lora_value_to_ruby(ruby, v)?)?;
|
|
265
|
+
}
|
|
266
|
+
Ok(h.as_value())
|
|
267
|
+
}
|
|
268
|
+
LoraValue::Node(id) => {
|
|
269
|
+
let h = ruby.hash_new();
|
|
270
|
+
h.aset(ruby.str_new("kind"), ruby.str_new("node"))?;
|
|
271
|
+
h.aset(ruby.str_new("id"), ruby.integer_from_i64(*id as i64))?;
|
|
272
|
+
h.aset(ruby.str_new("labels"), ruby.ary_new())?;
|
|
273
|
+
h.aset(ruby.str_new("properties"), ruby.hash_new())?;
|
|
274
|
+
Ok(h.as_value())
|
|
275
|
+
}
|
|
276
|
+
LoraValue::Relationship(id) => {
|
|
277
|
+
let h = ruby.hash_new();
|
|
278
|
+
h.aset(ruby.str_new("kind"), ruby.str_new("relationship"))?;
|
|
279
|
+
h.aset(ruby.str_new("id"), ruby.integer_from_i64(*id as i64))?;
|
|
280
|
+
Ok(h.as_value())
|
|
281
|
+
}
|
|
282
|
+
LoraValue::Path(p) => {
|
|
283
|
+
let h = ruby.hash_new();
|
|
284
|
+
h.aset(ruby.str_new("kind"), ruby.str_new("path"))?;
|
|
285
|
+
let nodes = ruby.ary_new();
|
|
286
|
+
for n in &p.nodes {
|
|
287
|
+
nodes.push(ruby.integer_from_i64(*n as i64))?;
|
|
288
|
+
}
|
|
289
|
+
let rels = ruby.ary_new();
|
|
290
|
+
for r in &p.rels {
|
|
291
|
+
rels.push(ruby.integer_from_i64(*r as i64))?;
|
|
292
|
+
}
|
|
293
|
+
h.aset(ruby.str_new("nodes"), nodes)?;
|
|
294
|
+
h.aset(ruby.str_new("rels"), rels)?;
|
|
295
|
+
Ok(h.as_value())
|
|
296
|
+
}
|
|
297
|
+
LoraValue::Date(v) => tagged_iso(ruby, "date", v.to_string()),
|
|
298
|
+
LoraValue::Time(v) => tagged_iso(ruby, "time", v.to_string()),
|
|
299
|
+
LoraValue::LocalTime(v) => tagged_iso(ruby, "localtime", v.to_string()),
|
|
300
|
+
LoraValue::DateTime(v) => tagged_iso(ruby, "datetime", v.to_string()),
|
|
301
|
+
LoraValue::LocalDateTime(v) => tagged_iso(ruby, "localdatetime", v.to_string()),
|
|
302
|
+
LoraValue::Duration(v) => tagged_iso(ruby, "duration", v.to_string()),
|
|
303
|
+
LoraValue::Point(p) => point_to_ruby(ruby, p),
|
|
304
|
+
LoraValue::Vector(v) => vector_to_ruby(ruby, v),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn vector_to_ruby(ruby: &Ruby, v: &LoraVector) -> Result<Value, MagnusError> {
|
|
309
|
+
let h = ruby.hash_new();
|
|
310
|
+
h.aset(ruby.str_new("kind"), ruby.str_new("vector"))?;
|
|
311
|
+
h.aset(
|
|
312
|
+
ruby.str_new("dimension"),
|
|
313
|
+
ruby.integer_from_i64(v.dimension as i64),
|
|
314
|
+
)?;
|
|
315
|
+
h.aset(
|
|
316
|
+
ruby.str_new("coordinateType"),
|
|
317
|
+
ruby.str_new(v.coordinate_type().as_str()),
|
|
318
|
+
)?;
|
|
319
|
+
|
|
320
|
+
let values = ruby.ary_new();
|
|
321
|
+
match &v.values {
|
|
322
|
+
VectorValues::Float64(vs) => {
|
|
323
|
+
for x in vs {
|
|
324
|
+
values.push(ruby.float_from_f64(*x))?;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
VectorValues::Float32(vs) => {
|
|
328
|
+
for x in vs {
|
|
329
|
+
values.push(ruby.float_from_f64(*x as f64))?;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
VectorValues::Integer64(vs) => {
|
|
333
|
+
for x in vs {
|
|
334
|
+
values.push(ruby.integer_from_i64(*x))?;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
VectorValues::Integer32(vs) => {
|
|
338
|
+
for x in vs {
|
|
339
|
+
values.push(ruby.integer_from_i64(*x as i64))?;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
VectorValues::Integer16(vs) => {
|
|
343
|
+
for x in vs {
|
|
344
|
+
values.push(ruby.integer_from_i64(*x as i64))?;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
VectorValues::Integer8(vs) => {
|
|
348
|
+
for x in vs {
|
|
349
|
+
values.push(ruby.integer_from_i64(*x as i64))?;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
h.aset(ruby.str_new("values"), values)?;
|
|
354
|
+
Ok(h.as_value())
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn tagged_iso(ruby: &Ruby, kind: &str, iso: String) -> Result<Value, MagnusError> {
|
|
358
|
+
let h = ruby.hash_new();
|
|
359
|
+
h.aset(ruby.str_new("kind"), ruby.str_new(kind))?;
|
|
360
|
+
h.aset(ruby.str_new("iso"), ruby.str_new(&iso))?;
|
|
361
|
+
Ok(h.as_value())
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// Render a `LoraPoint` into the canonical external point shape — kept
|
|
365
|
+
/// 1:1 aligned with the `LoraPoint` union emitted by `lora-node` /
|
|
366
|
+
/// `lora-wasm` / `lora-python`.
|
|
367
|
+
fn point_to_ruby(ruby: &Ruby, p: &LoraPoint) -> Result<Value, MagnusError> {
|
|
368
|
+
let h = ruby.hash_new();
|
|
369
|
+
h.aset(ruby.str_new("kind"), ruby.str_new("point"))?;
|
|
370
|
+
h.aset(ruby.str_new("srid"), ruby.integer_from_i64(p.srid as i64))?;
|
|
371
|
+
h.aset(ruby.str_new("crs"), ruby.str_new(p.crs_name()))?;
|
|
372
|
+
h.aset(ruby.str_new("x"), ruby.float_from_f64(p.x))?;
|
|
373
|
+
h.aset(ruby.str_new("y"), ruby.float_from_f64(p.y))?;
|
|
374
|
+
if let Some(z) = p.z {
|
|
375
|
+
h.aset(ruby.str_new("z"), ruby.float_from_f64(z))?;
|
|
376
|
+
}
|
|
377
|
+
if p.is_geographic() {
|
|
378
|
+
h.aset(
|
|
379
|
+
ruby.str_new("longitude"),
|
|
380
|
+
ruby.float_from_f64(p.longitude()),
|
|
381
|
+
)?;
|
|
382
|
+
h.aset(ruby.str_new("latitude"), ruby.float_from_f64(p.latitude()))?;
|
|
383
|
+
if let Some(height) = p.height() {
|
|
384
|
+
h.aset(ruby.str_new("height"), ruby.float_from_f64(height))?;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
Ok(h.as_value())
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// Ruby → LoraValue (params)
|
|
392
|
+
// ============================================================================
|
|
393
|
+
|
|
394
|
+
fn ruby_value_to_params(
|
|
395
|
+
ruby: &Ruby,
|
|
396
|
+
value: Value,
|
|
397
|
+
) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
|
|
398
|
+
let hash = RHash::try_convert(value)
|
|
399
|
+
.map_err(|_| invalid_params(ruby, "params must be a Hash keyed by parameter name"))?;
|
|
400
|
+
hash_to_string_map(ruby, hash)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
fn hash_to_string_map(
|
|
404
|
+
ruby: &Ruby,
|
|
405
|
+
hash: RHash,
|
|
406
|
+
) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
|
|
407
|
+
let mut out = BTreeMap::new();
|
|
408
|
+
let mut inner_err: Option<MagnusError> = None;
|
|
409
|
+
hash.foreach(|k: Value, v: Value| {
|
|
410
|
+
let key = match coerce_key(ruby, k) {
|
|
411
|
+
Ok(s) => s,
|
|
412
|
+
Err(e) => {
|
|
413
|
+
inner_err = Some(e);
|
|
414
|
+
return Ok(ForEach::Stop);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
match ruby_value_to_lora(ruby, v) {
|
|
418
|
+
Ok(lv) => {
|
|
419
|
+
out.insert(key, lv);
|
|
420
|
+
Ok(ForEach::Continue)
|
|
421
|
+
}
|
|
422
|
+
Err(e) => {
|
|
423
|
+
inner_err = Some(e);
|
|
424
|
+
Ok(ForEach::Stop)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
})?;
|
|
428
|
+
if let Some(e) = inner_err {
|
|
429
|
+
return Err(e);
|
|
430
|
+
}
|
|
431
|
+
Ok(out)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
fn coerce_key(ruby: &Ruby, v: Value) -> Result<String, MagnusError> {
|
|
435
|
+
// Accept both String and Symbol keys — idiomatic Ruby. Reject anything
|
|
436
|
+
// else loudly; silently stringifying would mask caller mistakes.
|
|
437
|
+
if let Ok(s) = RString::try_convert(v) {
|
|
438
|
+
return s.to_string();
|
|
439
|
+
}
|
|
440
|
+
if let Ok(s) = Symbol::try_convert(v) {
|
|
441
|
+
return Ok(s.name()?.into_owned());
|
|
442
|
+
}
|
|
443
|
+
Err(invalid_params(ruby, "param keys must be String or Symbol"))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn ruby_value_to_lora(ruby: &Ruby, v: Value) -> Result<LoraValue, MagnusError> {
|
|
447
|
+
if v.is_nil() {
|
|
448
|
+
return Ok(LoraValue::Null);
|
|
449
|
+
}
|
|
450
|
+
// Check true/false before Integer — Ruby's TrueClass / FalseClass are
|
|
451
|
+
// not Integer subclasses, but bool detection is cleaner first.
|
|
452
|
+
if v.is_kind_of(ruby.class_true_class()) {
|
|
453
|
+
return Ok(LoraValue::Bool(true));
|
|
454
|
+
}
|
|
455
|
+
if v.is_kind_of(ruby.class_false_class()) {
|
|
456
|
+
return Ok(LoraValue::Bool(false));
|
|
457
|
+
}
|
|
458
|
+
// Float MUST be checked before Integer — `Integer::try_convert`
|
|
459
|
+
// succeeds on Float because Ruby's `Float#to_int` (truncating
|
|
460
|
+
// coercion) makes `Float` implicitly convertible. Taking that path
|
|
461
|
+
// would turn `1.5` into `1` silently; callers never want that.
|
|
462
|
+
if let Ok(f) = Float::try_convert(v) {
|
|
463
|
+
return Ok(LoraValue::Float(f.to_f64()));
|
|
464
|
+
}
|
|
465
|
+
if let Ok(i) = Integer::try_convert(v) {
|
|
466
|
+
return match i.to_i64() {
|
|
467
|
+
Ok(n) => Ok(LoraValue::Int(n)),
|
|
468
|
+
Err(_) => Err(invalid_params(
|
|
469
|
+
ruby,
|
|
470
|
+
"integer parameter does not fit in i64",
|
|
471
|
+
)),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if let Ok(s) = RString::try_convert(v) {
|
|
475
|
+
return Ok(LoraValue::String(s.to_string()?));
|
|
476
|
+
}
|
|
477
|
+
if let Ok(sym) = Symbol::try_convert(v) {
|
|
478
|
+
// Symbols round-trip as strings — same approach as YAML/JSON
|
|
479
|
+
// mappings. Engine has no dedicated symbol value.
|
|
480
|
+
return Ok(LoraValue::String(sym.name()?.into_owned()));
|
|
481
|
+
}
|
|
482
|
+
if let Ok(arr) = RArray::try_convert(v) {
|
|
483
|
+
let mut out = Vec::with_capacity(arr.len());
|
|
484
|
+
for item in arr.into_iter() {
|
|
485
|
+
out.push(ruby_value_to_lora(ruby, item)?);
|
|
486
|
+
}
|
|
487
|
+
return Ok(LoraValue::List(out));
|
|
488
|
+
}
|
|
489
|
+
if let Ok(hash) = RHash::try_convert(v) {
|
|
490
|
+
return ruby_hash_to_cypher(ruby, hash);
|
|
491
|
+
}
|
|
492
|
+
let class_name = unsafe { v.classname() }.into_owned();
|
|
493
|
+
Err(invalid_params(
|
|
494
|
+
ruby,
|
|
495
|
+
format!("unsupported parameter type: {class_name}"),
|
|
496
|
+
))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/// A Hash might be a tagged value (date / time / …/ point) or a plain
|
|
500
|
+
/// map. Nodes / relationships / paths are opaque on the engine side and
|
|
501
|
+
/// cannot be reconstructed as params — there's no `"kind" => "node"`
|
|
502
|
+
/// tag handled here.
|
|
503
|
+
fn ruby_hash_to_cypher(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
|
|
504
|
+
if let Some(kind) = lookup_kind(ruby, hash)? {
|
|
505
|
+
match kind.as_str() {
|
|
506
|
+
"date" => {
|
|
507
|
+
return parse_tagged(ruby, hash, "date", |iso| {
|
|
508
|
+
LoraDate::parse(iso).map(LoraValue::Date)
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
"time" => {
|
|
512
|
+
return parse_tagged(ruby, hash, "time", |iso| {
|
|
513
|
+
LoraTime::parse(iso).map(LoraValue::Time)
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
"localtime" => {
|
|
517
|
+
return parse_tagged(ruby, hash, "localtime", |iso| {
|
|
518
|
+
LoraLocalTime::parse(iso).map(LoraValue::LocalTime)
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
"datetime" => {
|
|
522
|
+
return parse_tagged(ruby, hash, "datetime", |iso| {
|
|
523
|
+
LoraDateTime::parse(iso).map(LoraValue::DateTime)
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
"localdatetime" => {
|
|
527
|
+
return parse_tagged(ruby, hash, "localdatetime", |iso| {
|
|
528
|
+
LoraLocalDateTime::parse(iso).map(LoraValue::LocalDateTime)
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
"duration" => {
|
|
532
|
+
return parse_tagged(ruby, hash, "duration", |iso| {
|
|
533
|
+
LoraDuration::parse(iso).map(LoraValue::Duration)
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
"point" => return build_point(ruby, hash),
|
|
537
|
+
"vector" => return build_vector(ruby, hash),
|
|
538
|
+
_ => { /* fall through to plain-map handling */ }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
Ok(LoraValue::Map(hash_to_string_map(ruby, hash)?))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/// Look up `"kind"` (string) or `:kind` (symbol) under either key. Keeps
|
|
546
|
+
/// constructor hashes usable with either Ruby idiom.
|
|
547
|
+
fn lookup_kind(ruby: &Ruby, hash: RHash) -> Result<Option<String>, MagnusError> {
|
|
548
|
+
if let Some(v) = hash.get(ruby.str_new("kind")) {
|
|
549
|
+
return kind_as_string(v).map(Some);
|
|
550
|
+
}
|
|
551
|
+
if let Some(v) = hash.get(ruby.to_symbol("kind")) {
|
|
552
|
+
return kind_as_string(v).map(Some);
|
|
553
|
+
}
|
|
554
|
+
Ok(None)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fn kind_as_string(v: Value) -> Result<String, MagnusError> {
|
|
558
|
+
if let Ok(s) = RString::try_convert(v) {
|
|
559
|
+
return s.to_string();
|
|
560
|
+
}
|
|
561
|
+
if let Ok(s) = Symbol::try_convert(v) {
|
|
562
|
+
return Ok(s.name()?.into_owned());
|
|
563
|
+
}
|
|
564
|
+
// Anything else means "not a tagged constructor" — return empty so
|
|
565
|
+
// the caller falls through to plain-map handling instead of raising.
|
|
566
|
+
Ok(String::new())
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
fn parse_tagged(
|
|
570
|
+
ruby: &Ruby,
|
|
571
|
+
hash: RHash,
|
|
572
|
+
tag: &str,
|
|
573
|
+
parse: impl FnOnce(&str) -> Result<LoraValue, String>,
|
|
574
|
+
) -> Result<LoraValue, MagnusError> {
|
|
575
|
+
let iso = read_string(ruby, hash, "iso")?
|
|
576
|
+
.ok_or_else(|| invalid_params(ruby, format!("{tag} value requires iso: String")))?;
|
|
577
|
+
parse(&iso).map_err(|e| invalid_params(ruby, format!("{tag}: {e}")))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
fn build_point(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
|
|
581
|
+
let srid = read_u32(ruby, hash, "srid")?.unwrap_or(7203);
|
|
582
|
+
let x = read_f64(ruby, hash, "x")?.ok_or_else(|| invalid_params(ruby, "point.x required"))?;
|
|
583
|
+
let y = read_f64(ruby, hash, "y")?.ok_or_else(|| invalid_params(ruby, "point.y required"))?;
|
|
584
|
+
let z = read_f64(ruby, hash, "z")?;
|
|
585
|
+
Ok(LoraValue::Point(LoraPoint { x, y, z, srid }))
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
|
|
589
|
+
let dimension = read_i64(ruby, hash, "dimension")?
|
|
590
|
+
.ok_or_else(|| invalid_params(ruby, "vector.dimension required"))?;
|
|
591
|
+
let coordinate_type_name = read_string(ruby, hash, "coordinateType")?
|
|
592
|
+
.ok_or_else(|| invalid_params(ruby, "vector.coordinateType required"))?;
|
|
593
|
+
let coordinate_type = VectorCoordinateType::parse(&coordinate_type_name).ok_or_else(|| {
|
|
594
|
+
invalid_params(
|
|
595
|
+
ruby,
|
|
596
|
+
format!("unknown vector coordinate type '{coordinate_type_name}'"),
|
|
597
|
+
)
|
|
598
|
+
})?;
|
|
599
|
+
let values_value = hash_get_either(ruby, hash, "values")
|
|
600
|
+
.ok_or_else(|| invalid_params(ruby, "vector.values required"))?;
|
|
601
|
+
let arr = RArray::try_convert(values_value)
|
|
602
|
+
.map_err(|_| invalid_params(ruby, "vector.values must be an Array"))?;
|
|
603
|
+
|
|
604
|
+
let mut raw = Vec::with_capacity(arr.len());
|
|
605
|
+
for item in arr.into_iter() {
|
|
606
|
+
if item.is_kind_of(ruby.class_true_class()) || item.is_kind_of(ruby.class_false_class()) {
|
|
607
|
+
return Err(invalid_params(
|
|
608
|
+
ruby,
|
|
609
|
+
"vector.values entries must be numeric",
|
|
610
|
+
));
|
|
611
|
+
}
|
|
612
|
+
if let Ok(f) = Float::try_convert(item) {
|
|
613
|
+
let v = f.to_f64();
|
|
614
|
+
if !v.is_finite() {
|
|
615
|
+
return Err(invalid_params(
|
|
616
|
+
ruby,
|
|
617
|
+
"vector.values cannot be NaN or Infinity",
|
|
618
|
+
));
|
|
619
|
+
}
|
|
620
|
+
raw.push(RawCoordinate::Float(v));
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if let Ok(i) = Integer::try_convert(item) {
|
|
624
|
+
raw.push(RawCoordinate::Int(i.to_i64()?));
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
return Err(invalid_params(
|
|
628
|
+
ruby,
|
|
629
|
+
"vector.values entries must be numeric",
|
|
630
|
+
));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let v = LoraVector::try_new(raw, dimension, coordinate_type)
|
|
634
|
+
.map_err(|e| invalid_params(ruby, e.to_string()))?;
|
|
635
|
+
Ok(LoraValue::Vector(v))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
fn read_i64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<i64>, MagnusError> {
|
|
639
|
+
let Some(v) = hash_get_either(ruby, hash, key) else {
|
|
640
|
+
return Ok(None);
|
|
641
|
+
};
|
|
642
|
+
Ok(Some(Integer::try_convert(v)?.to_i64().map_err(|_| {
|
|
643
|
+
invalid_params(ruby, format!("{key} out of i64 range"))
|
|
644
|
+
})?))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ---- Hash accessors that accept either string or symbol keys ------------
|
|
648
|
+
|
|
649
|
+
fn hash_get_either(ruby: &Ruby, hash: RHash, key: &str) -> Option<Value> {
|
|
650
|
+
if let Some(v) = hash.get(ruby.str_new(key)) {
|
|
651
|
+
return Some(v);
|
|
652
|
+
}
|
|
653
|
+
hash.get(ruby.to_symbol(key))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
fn read_string(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<String>, MagnusError> {
|
|
657
|
+
let Some(v) = hash_get_either(ruby, hash, key) else {
|
|
658
|
+
return Ok(None);
|
|
659
|
+
};
|
|
660
|
+
let s = RString::try_convert(v)?.to_string()?;
|
|
661
|
+
Ok(Some(s))
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
fn read_u32(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<u32>, MagnusError> {
|
|
665
|
+
let Some(v) = hash_get_either(ruby, hash, key) else {
|
|
666
|
+
return Ok(None);
|
|
667
|
+
};
|
|
668
|
+
let n = Integer::try_convert(v)?.to_i64()?;
|
|
669
|
+
u32::try_from(n)
|
|
670
|
+
.map(Some)
|
|
671
|
+
.map_err(|_| invalid_params(ruby, "srid out of u32 range"))
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fn read_f64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<f64>, MagnusError> {
|
|
675
|
+
let Some(v) = hash_get_either(ruby, hash, key) else {
|
|
676
|
+
return Ok(None);
|
|
677
|
+
};
|
|
678
|
+
// Accept either Float or Integer — `cartesian(1, 2)` passing ints
|
|
679
|
+
// shouldn't force the caller to call `.to_f` first.
|
|
680
|
+
if let Ok(f) = Float::try_convert(v) {
|
|
681
|
+
return Ok(Some(f.to_f64()));
|
|
682
|
+
}
|
|
683
|
+
if let Ok(i) = Integer::try_convert(v) {
|
|
684
|
+
return Ok(Some(i.to_i64()? as f64));
|
|
685
|
+
}
|
|
686
|
+
Ok(None)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ============================================================================
|
|
690
|
+
// GVL release
|
|
691
|
+
// ============================================================================
|
|
692
|
+
|
|
693
|
+
/// Run `f` with Ruby's Global VM Lock released.
|
|
694
|
+
///
|
|
695
|
+
/// Semantics match `rb_thread_call_without_gvl` — other Ruby threads can
|
|
696
|
+
/// progress while `f` runs. The closure MUST NOT touch Ruby state (no
|
|
697
|
+
/// `Value`s, no allocations into the Ruby heap), which we arrange by
|
|
698
|
+
/// keeping all such work on the calling thread. Everything inside
|
|
699
|
+
/// `database_execute`'s closure is pure Rust on pre-extracted data, so
|
|
700
|
+
/// this is sound.
|
|
701
|
+
fn without_gvl<F, R>(f: F) -> R
|
|
702
|
+
where
|
|
703
|
+
F: FnOnce() -> R,
|
|
704
|
+
F: Send,
|
|
705
|
+
R: Send,
|
|
706
|
+
{
|
|
707
|
+
struct Data<F, R> {
|
|
708
|
+
func: Option<F>,
|
|
709
|
+
result: MaybeUninit<R>,
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
|
|
713
|
+
where
|
|
714
|
+
F: FnOnce() -> R,
|
|
715
|
+
{
|
|
716
|
+
let data = &mut *(data as *mut Data<F, R>);
|
|
717
|
+
let f = data
|
|
718
|
+
.func
|
|
719
|
+
.take()
|
|
720
|
+
.expect("without_gvl: closure already taken");
|
|
721
|
+
data.result.write(f());
|
|
722
|
+
std::ptr::null_mut()
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let mut data = Data::<F, R> {
|
|
726
|
+
func: Some(f),
|
|
727
|
+
result: MaybeUninit::uninit(),
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
unsafe {
|
|
731
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
732
|
+
Some(trampoline::<F, R>),
|
|
733
|
+
&mut data as *mut _ as *mut c_void,
|
|
734
|
+
// No unblock function — the engine doesn't implement
|
|
735
|
+
// cooperative cancellation, and a forced longjmp out of a
|
|
736
|
+
// mutex-holding section would be worse than waiting.
|
|
737
|
+
None,
|
|
738
|
+
std::ptr::null_mut(),
|
|
739
|
+
);
|
|
740
|
+
data.result.assume_init()
|
|
741
|
+
}
|
|
742
|
+
}
|