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.
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
+ }