lora-ruby 0.6.0 → 0.8.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3a9450cc524f6ddb0431cab4516dcf68c064a8b6a489e9a920284976b07c624
4
- data.tar.gz: 132681134206f3fc223cb2238079f2bbc691b201eaeca4771950fdc59788f72d
3
+ metadata.gz: b8997fb6d383285f5875b2fcd1df32e6299ece787a9c3fdc5bf48327ec68c543
4
+ data.tar.gz: 2c319c8b8585c5a66c13ed5c37a3fc28a77e2bb5422d62011389d62358cbefbe
5
5
  SHA512:
6
- metadata.gz: e08270ea4b1946e2d86bd44ffc40860c12e3c8b1814a8aff02762934c0b0a836a2a4acfc01b2953082f07b863ff21836eb9f2ffb20872c3a8dcf972afd25c3c2
7
- data.tar.gz: 604298f36756dd70d2ccce2072396db1b508b39a2c1413f2d3a660648be4131e011934314c9d1f260baa7a88be5b012f34feb2cbf318a2261176cde0eb19d39b
6
+ metadata.gz: d4a50e3ac27b694e2a7de3350f28d0d511c7578f4f96091ae797accc6c2a34195b68419605c1be44e3615cb7841ae204b85d97c73eaaf27fa7b746a9b3766464
7
+ data.tar.gz: b08fa81cc99b67eb037ae3fc51d48019d58b7609a6c2f404e9b662c1319bbd13f75d394f02039b8aa3780f0e52de47116aa4954bea3e4a521d4f9c25b74e3764
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # lora-ruby
2
2
 
3
- Ruby bindings for the [Lora](../../README.md) graph engine.
3
+ Ruby bindings for the [Lora](../../../README.md) graph engine.
4
4
  Ships a native extension built with [Magnus](https://github.com/matsadler/magnus)
5
5
  on top of [`rb-sys`](https://github.com/oxidize-rb/rb-sys) so the Rust
6
6
  engine runs in-process — no separate server, no socket hop.
@@ -81,6 +81,8 @@ LoraRuby::Database.new(database_name = nil, options = nil) # -> Database
81
81
  LoraRuby::Database.open_wal(wal_dir, options = nil) # -> Database
82
82
 
83
83
  db.execute(query, params = nil) # -> { "columns" => [...], "rows" => [...] }
84
+ db.explain(query, params = nil) # -> plan Hash; never executes
85
+ db.profile(query, params = nil) # -> { "plan" => ..., "metrics" => ... }; PROFILE executes writes
84
86
  db.clear # -> nil
85
87
  db.node_count # -> Integer
86
88
  db.relationship_count # -> Integer
@@ -102,6 +104,37 @@ Hash keys on the output are always **strings**, matching the `lora-node`,
102
104
  symbol or string keys — both work for param names and for tagged
103
105
  constructor Hashes like `point`/`date`/...
104
106
 
107
+ ### Explain & Profile
108
+
109
+ `db.explain` and `db.profile` are first-class methods alongside
110
+ `db.execute`. They are intentionally *separate calls*, not a flag on
111
+ `execute`, so plan inspection and runtime metrics must be requested
112
+ explicitly.
113
+
114
+ ```ruby
115
+ plan = db.explain(
116
+ "MATCH (p:Person) WHERE p.name = $name RETURN p",
117
+ { "name" => "Alice" }
118
+ )
119
+ plan["shape"] # => "readOnly"
120
+ plan["tree"]["operator"]
121
+
122
+ profile = db.profile(
123
+ "MATCH (p:Person) WHERE p.name = $name RETURN p",
124
+ { "name" => "Alice" }
125
+ )
126
+ profile["metrics"]["total_elapsed_ns"]
127
+ profile["metrics"]["per_operator"] # per-step inclusive timing
128
+ ```
129
+
130
+ `explain` never invokes the executor — calling it on a mutating query
131
+ (`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`) leaves the graph
132
+ untouched.
133
+
134
+ > **`profile` executes the query for real.** Mutating queries are
135
+ > persisted exactly as in `execute`. Use `explain` to inspect a
136
+ > mutating plan without running it.
137
+
105
138
  ## Typed value model
106
139
 
107
140
  Identical contract to the other bindings:
@@ -174,9 +207,8 @@ db = LoraRuby::Database.open_wal(
174
207
  ## Concurrency (GVL release)
175
208
 
176
209
  `Database#execute` calls `rb_thread_call_without_gvl`, so other Ruby
177
- threads run while the engine is busy. Concurrent queries against the
178
- same `Database` serialise on an internal `Mutex`; parallel queries
179
- against **different** `Database` instances have no shared state.
210
+ threads run while the engine is busy. Auto-commit reads can overlap on engine
211
+ snapshots; write commits and explicit read-write transactions serialize.
180
212
 
181
213
  The engine has no cancellation hook, so we pass a `NULL` unblock
182
214
  function. A thread interrupted mid-query (`Thread#kill`) will observe
@@ -186,7 +218,7 @@ if you rely on cooperative cancellation.
186
218
  ## Local development
187
219
 
188
220
  ```bash
189
- cd crates/lora-ruby
221
+ cd crates/bindings/lora-ruby
190
222
  bundle install
191
223
  bundle exec rake compile # cargo build → lib/lora_ruby/lora_ruby.<ext>
192
224
  bundle exec rake test # minitest
@@ -201,7 +233,7 @@ precompiled platform gem.
201
233
 
202
234
  ```
203
235
  lora-database (Rust, embedded)
204
- └── crates/lora-ruby/ (gem root + cargo crate)
236
+ └── crates/bindings/lora-ruby/ (gem root + cargo crate)
205
237
  ├── Cargo.toml Rust workspace member
206
238
  ├── extconf.rb rb-sys / mkmf entry point
207
239
  ├── src/lib.rs <- Magnus / rb-sys bindings
data/extconf.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # rb-sys provides a drop-in replacement for `create_makefile` that shells
4
- # out to cargo to build the `cdylib` declared in ../../Cargo.toml. The
4
+ # out to cargo to build the `cdylib` declared in ../../../Cargo.toml. The
5
5
  # path it writes the native library to is determined by the argument —
6
6
  # `lora_ruby/lora_ruby` places the final artefact at
7
7
  # `lib/lora_ruby/lora_ruby.{so,bundle,dll}`, which is what
@@ -10,5 +10,5 @@ module LoraRuby
10
10
  # Guard against redefinition so re-requiring this file (or loading
11
11
  # both paths) doesn't emit a "warning: already initialized constant"
12
12
  # when the native extension loads second with the identical value.
13
- VERSION = "0.6.0" unless const_defined?(:VERSION)
13
+ VERSION = "0.8.4" unless const_defined?(:VERSION)
14
14
  end
data/src/errors.rs ADDED
@@ -0,0 +1,74 @@
1
+ //! Lookup helpers for the `LoraRuby::Error` exception hierarchy.
2
+ //!
3
+ //! The exception classes themselves are registered in `lib.rs::init` so
4
+ //! Ruby owns their lifetime; these helpers re-find them by name when a
5
+ //! method needs to raise. `unwrap_or_else(|_| ruby.exception_standard_error())`
6
+ //! keeps us safe even if a constant is shadowed at runtime — we still
7
+ //! raise *something* descended from `StandardError`.
8
+
9
+ use lora_database::{LoraError, LoraErrorCode};
10
+ use magnus::{prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
11
+
12
+ pub(crate) fn lora_module(ruby: &Ruby) -> RModule {
13
+ ruby.class_object()
14
+ .const_get::<_, RModule>("LoraRuby")
15
+ .expect("LoraRuby module is defined by `init` before any method runs")
16
+ }
17
+
18
+ pub(crate) fn lora_error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
19
+ // `const_get::<_, ExceptionClass>` converts the stored RClass into
20
+ // an ExceptionClass — this is the sound path, because our subclasses
21
+ // of StandardError retain the exception-class trait on the Ruby
22
+ // side even though `define_class` typed them as RClass.
23
+ lora_module(ruby)
24
+ .const_get::<_, ExceptionClass>(name)
25
+ .unwrap_or_else(|_| ruby.exception_standard_error())
26
+ }
27
+
28
+ /// Raise a `LoraRuby::QueryError`, prefixing the message with the
29
+ /// precise wire code from [`LoraErrorCode`] so Ruby callers can route
30
+ /// past the exception class via `e.message.split(': ', 2)`.
31
+ pub(crate) fn query_error(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
32
+ let raw: String = msg.into();
33
+ let body = if has_code_prefix(&raw) {
34
+ raw
35
+ } else {
36
+ format!("{}: {raw}", LoraErrorCode::Internal.as_str())
37
+ };
38
+ MagnusError::new(lora_error_class(ruby, "QueryError"), body)
39
+ }
40
+
41
+ /// Raise a `LoraRuby::InvalidParamsError`, prefixed with
42
+ /// `LORA_INVALID_PARAMS:` so callers can route uniformly with the
43
+ /// other bindings.
44
+ pub(crate) fn invalid_params(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
45
+ let raw: String = msg.into();
46
+ let body = if has_code_prefix(&raw) {
47
+ raw
48
+ } else {
49
+ format!("{}: {raw}", LoraErrorCode::InvalidParams.as_str())
50
+ };
51
+ MagnusError::new(lora_error_class(ruby, "InvalidParamsError"), body)
52
+ }
53
+
54
+ /// Build a `LoraRuby::QueryError` from any error convertible into
55
+ /// [`LoraError`]. Accepts both the engine's typed `LoraError` and the
56
+ /// binding-internal `anyhow::Error` (via `From<anyhow::Error>`), so
57
+ /// query and admin paths share one helper.
58
+ #[allow(dead_code)]
59
+ pub(crate) fn query_error_from_anyhow(ruby: &Ruby, err: impl Into<LoraError>) -> MagnusError {
60
+ let lora = err.into();
61
+ let body = format!("{}: {}", lora.code().as_str(), lora.message());
62
+ MagnusError::new(lora_error_class(ruby, "QueryError"), body)
63
+ }
64
+
65
+ fn has_code_prefix(s: &str) -> bool {
66
+ // Detect `LORA_<UPPER_SNAKE>:` so callers that already produced a
67
+ // coded message (e.g. by passing through `query_error_from_anyhow`)
68
+ // are not double-prefixed.
69
+ let Some(colon) = s.find(':') else {
70
+ return false;
71
+ };
72
+ let head = &s[..colon];
73
+ head.starts_with("LORA_") && head.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
74
+ }
data/src/from_ruby.rs ADDED
@@ -0,0 +1,419 @@
1
+ //! Ruby → `LoraValue` conversion (params and snapshot-option JSON).
2
+ //!
3
+ //! Inverse of [`crate::to_ruby`]. Tagged hashes (`{"kind" => "date", …}`)
4
+ //! become temporal/spatial values; plain hashes become `LoraValue::Map`.
5
+ //! Symbol keys and string keys are accepted interchangeably.
6
+
7
+ use std::collections::BTreeMap;
8
+
9
+ use magnus::{
10
+ prelude::*, r_hash::ForEach, value::ReprValue, Error as MagnusError, Float, Integer, RArray,
11
+ RHash, RString, Ruby, Symbol, Value,
12
+ };
13
+
14
+ use lora_database::LoraValue;
15
+ use lora_store::{
16
+ LoraBinary, LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint,
17
+ LoraTime, LoraVector, RawCoordinate, VectorCoordinateType,
18
+ };
19
+
20
+ use crate::errors::invalid_params;
21
+
22
+ pub(crate) fn ruby_value_to_params(
23
+ ruby: &Ruby,
24
+ value: Value,
25
+ ) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
26
+ let hash = RHash::try_convert(value)
27
+ .map_err(|_| invalid_params(ruby, "params must be a Hash keyed by parameter name"))?;
28
+ hash_to_string_map(ruby, hash)
29
+ }
30
+
31
+ fn hash_to_string_map(
32
+ ruby: &Ruby,
33
+ hash: RHash,
34
+ ) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
35
+ let mut out = BTreeMap::new();
36
+ let mut inner_err: Option<MagnusError> = None;
37
+ hash.foreach(|k: Value, v: Value| {
38
+ let key = match coerce_key(ruby, k) {
39
+ Ok(s) => s,
40
+ Err(e) => {
41
+ inner_err = Some(e);
42
+ return Ok(ForEach::Stop);
43
+ }
44
+ };
45
+ match ruby_value_to_lora(ruby, v) {
46
+ Ok(lv) => {
47
+ out.insert(key, lv);
48
+ Ok(ForEach::Continue)
49
+ }
50
+ Err(e) => {
51
+ inner_err = Some(e);
52
+ Ok(ForEach::Stop)
53
+ }
54
+ }
55
+ })?;
56
+ if let Some(e) = inner_err {
57
+ return Err(e);
58
+ }
59
+ Ok(out)
60
+ }
61
+
62
+ fn coerce_key(ruby: &Ruby, v: Value) -> Result<String, MagnusError> {
63
+ // Accept both String and Symbol keys — idiomatic Ruby. Reject anything
64
+ // else loudly; silently stringifying would mask caller mistakes.
65
+ if let Ok(s) = RString::try_convert(v) {
66
+ return s.to_string();
67
+ }
68
+ if let Ok(s) = Symbol::try_convert(v) {
69
+ return Ok(s.name()?.into_owned());
70
+ }
71
+ Err(invalid_params(ruby, "param keys must be String or Symbol"))
72
+ }
73
+
74
+ pub(crate) fn ruby_optional_to_json(
75
+ ruby: &Ruby,
76
+ value: Value,
77
+ ) -> Result<Option<serde_json::Value>, MagnusError> {
78
+ if value.is_nil() {
79
+ Ok(None)
80
+ } else {
81
+ ruby_value_to_json(ruby, value).map(Some)
82
+ }
83
+ }
84
+
85
+ fn ruby_value_to_json(ruby: &Ruby, value: Value) -> Result<serde_json::Value, MagnusError> {
86
+ if value.is_nil() {
87
+ return Ok(serde_json::Value::Null);
88
+ }
89
+ if value.is_kind_of(ruby.class_true_class()) {
90
+ return Ok(serde_json::Value::Bool(true));
91
+ }
92
+ if value.is_kind_of(ruby.class_false_class()) {
93
+ return Ok(serde_json::Value::Bool(false));
94
+ }
95
+ if let Ok(i) = Integer::try_convert(value) {
96
+ let n = i
97
+ .to_i64()
98
+ .map_err(|_| invalid_params(ruby, "snapshot option integer does not fit in i64"))?;
99
+ return Ok(serde_json::Value::Number(n.into()));
100
+ }
101
+ if let Ok(f) = Float::try_convert(value) {
102
+ let Some(number) = serde_json::Number::from_f64(f.to_f64()) else {
103
+ return Err(invalid_params(ruby, "snapshot option float must be finite"));
104
+ };
105
+ return Ok(serde_json::Value::Number(number));
106
+ }
107
+ if let Ok(s) = RString::try_convert(value) {
108
+ return Ok(serde_json::Value::String(s.to_string()?));
109
+ }
110
+ if let Ok(sym) = Symbol::try_convert(value) {
111
+ return Ok(serde_json::Value::String(sym.name()?.into_owned()));
112
+ }
113
+ if let Ok(arr) = RArray::try_convert(value) {
114
+ let mut out = Vec::with_capacity(arr.len());
115
+ for item in arr.into_iter() {
116
+ out.push(ruby_value_to_json(ruby, item)?);
117
+ }
118
+ return Ok(serde_json::Value::Array(out));
119
+ }
120
+ if let Ok(hash) = RHash::try_convert(value) {
121
+ let mut out = serde_json::Map::new();
122
+ let mut error = None;
123
+ hash.foreach(|k: Value, v: Value| {
124
+ let key = match coerce_key(ruby, k) {
125
+ Ok(key) => key,
126
+ Err(e) => {
127
+ error = Some(e);
128
+ return Ok(ForEach::Stop);
129
+ }
130
+ };
131
+ let json = match ruby_value_to_json(ruby, v) {
132
+ Ok(json) => json,
133
+ Err(e) => {
134
+ error = Some(e);
135
+ return Ok(ForEach::Stop);
136
+ }
137
+ };
138
+ out.insert(key, json);
139
+ Ok(ForEach::Continue)
140
+ })?;
141
+ if let Some(error) = error {
142
+ return Err(error);
143
+ }
144
+ return Ok(serde_json::Value::Object(out));
145
+ }
146
+
147
+ let class_name = unsafe { value.classname() }.into_owned();
148
+ Err(invalid_params(
149
+ ruby,
150
+ format!("unsupported snapshot option type: {class_name}"),
151
+ ))
152
+ }
153
+
154
+ fn ruby_value_to_lora(ruby: &Ruby, v: Value) -> Result<LoraValue, MagnusError> {
155
+ if v.is_nil() {
156
+ return Ok(LoraValue::Null);
157
+ }
158
+ // Check true/false before Integer — Ruby's TrueClass / FalseClass are
159
+ // not Integer subclasses, but bool detection is cleaner first.
160
+ if v.is_kind_of(ruby.class_true_class()) {
161
+ return Ok(LoraValue::Bool(true));
162
+ }
163
+ if v.is_kind_of(ruby.class_false_class()) {
164
+ return Ok(LoraValue::Bool(false));
165
+ }
166
+ // Float MUST be checked before Integer — `Integer::try_convert`
167
+ // succeeds on Float because Ruby's `Float#to_int` (truncating
168
+ // coercion) makes `Float` implicitly convertible. Taking that path
169
+ // would turn `1.5` into `1` silently; callers never want that.
170
+ if let Ok(f) = Float::try_convert(v) {
171
+ return Ok(LoraValue::Float(f.to_f64()));
172
+ }
173
+ if let Ok(i) = Integer::try_convert(v) {
174
+ return match i.to_i64() {
175
+ Ok(n) => Ok(LoraValue::Int(n)),
176
+ Err(_) => Err(invalid_params(
177
+ ruby,
178
+ "integer parameter does not fit in i64",
179
+ )),
180
+ };
181
+ }
182
+ if let Ok(s) = RString::try_convert(v) {
183
+ return Ok(LoraValue::String(s.to_string()?));
184
+ }
185
+ if let Ok(sym) = Symbol::try_convert(v) {
186
+ // Symbols round-trip as strings — same approach as YAML/JSON
187
+ // mappings. Engine has no dedicated symbol value.
188
+ return Ok(LoraValue::String(sym.name()?.into_owned()));
189
+ }
190
+ if let Ok(arr) = RArray::try_convert(v) {
191
+ let mut out = Vec::with_capacity(arr.len());
192
+ for item in arr.into_iter() {
193
+ out.push(ruby_value_to_lora(ruby, item)?);
194
+ }
195
+ return Ok(LoraValue::List(out));
196
+ }
197
+ if let Ok(hash) = RHash::try_convert(v) {
198
+ return ruby_hash_to_cypher(ruby, hash);
199
+ }
200
+ let class_name = unsafe { v.classname() }.into_owned();
201
+ Err(invalid_params(
202
+ ruby,
203
+ format!("unsupported parameter type: {class_name}"),
204
+ ))
205
+ }
206
+
207
+ /// A Hash might be a tagged value (date / time / …/ point) or a plain
208
+ /// map. Nodes / relationships / paths are opaque on the engine side and
209
+ /// cannot be reconstructed as params — there's no `"kind" => "node"`
210
+ /// tag handled here.
211
+ fn ruby_hash_to_cypher(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
212
+ if let Some(kind) = lookup_kind(ruby, hash)? {
213
+ match kind.as_str() {
214
+ "date" => {
215
+ return parse_tagged(ruby, hash, "date", |iso| {
216
+ LoraDate::parse(iso).map(LoraValue::Date)
217
+ });
218
+ }
219
+ "time" => {
220
+ return parse_tagged(ruby, hash, "time", |iso| {
221
+ LoraTime::parse(iso).map(LoraValue::Time)
222
+ });
223
+ }
224
+ "localtime" => {
225
+ return parse_tagged(ruby, hash, "localtime", |iso| {
226
+ LoraLocalTime::parse(iso).map(LoraValue::LocalTime)
227
+ });
228
+ }
229
+ "datetime" => {
230
+ return parse_tagged(ruby, hash, "datetime", |iso| {
231
+ LoraDateTime::parse(iso).map(LoraValue::DateTime)
232
+ });
233
+ }
234
+ "localdatetime" => {
235
+ return parse_tagged(ruby, hash, "localdatetime", |iso| {
236
+ LoraLocalDateTime::parse(iso).map(LoraValue::LocalDateTime)
237
+ });
238
+ }
239
+ "duration" => {
240
+ return parse_tagged(ruby, hash, "duration", |iso| {
241
+ LoraDuration::parse(iso).map(LoraValue::Duration)
242
+ });
243
+ }
244
+ "point" => return build_point(ruby, hash),
245
+ "vector" => return build_vector(ruby, hash),
246
+ "binary" | "blob" => return build_binary(ruby, hash),
247
+ _ => { /* fall through to plain-map handling */ }
248
+ }
249
+ }
250
+
251
+ Ok(LoraValue::Map(hash_to_string_map(ruby, hash)?))
252
+ }
253
+
254
+ /// Look up `"kind"` (string) or `:kind` (symbol) under either key. Keeps
255
+ /// constructor hashes usable with either Ruby idiom.
256
+ fn lookup_kind(ruby: &Ruby, hash: RHash) -> Result<Option<String>, MagnusError> {
257
+ if let Some(v) = hash.get(ruby.str_new("kind")) {
258
+ return kind_as_string(v).map(Some);
259
+ }
260
+ if let Some(v) = hash.get(ruby.to_symbol("kind")) {
261
+ return kind_as_string(v).map(Some);
262
+ }
263
+ Ok(None)
264
+ }
265
+
266
+ fn kind_as_string(v: Value) -> Result<String, MagnusError> {
267
+ if let Ok(s) = RString::try_convert(v) {
268
+ return s.to_string();
269
+ }
270
+ if let Ok(s) = Symbol::try_convert(v) {
271
+ return Ok(s.name()?.into_owned());
272
+ }
273
+ // Anything else means "not a tagged constructor" — return empty so
274
+ // the caller falls through to plain-map handling instead of raising.
275
+ Ok(String::new())
276
+ }
277
+
278
+ fn parse_tagged(
279
+ ruby: &Ruby,
280
+ hash: RHash,
281
+ tag: &str,
282
+ parse: impl FnOnce(&str) -> Result<LoraValue, String>,
283
+ ) -> Result<LoraValue, MagnusError> {
284
+ let iso = read_string(ruby, hash, "iso")?
285
+ .ok_or_else(|| invalid_params(ruby, format!("{tag} value requires iso: String")))?;
286
+ parse(&iso).map_err(|e| invalid_params(ruby, format!("{tag}: {e}")))
287
+ }
288
+
289
+ fn build_point(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
290
+ let srid = read_u32(ruby, hash, "srid")?.unwrap_or(7203);
291
+ let x = read_f64(ruby, hash, "x")?.ok_or_else(|| invalid_params(ruby, "point.x required"))?;
292
+ let y = read_f64(ruby, hash, "y")?.ok_or_else(|| invalid_params(ruby, "point.y required"))?;
293
+ let z = read_f64(ruby, hash, "z")?;
294
+ Ok(LoraValue::Point(LoraPoint { x, y, z, srid }))
295
+ }
296
+
297
+ fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
298
+ let dimension = read_i64(ruby, hash, "dimension")?
299
+ .ok_or_else(|| invalid_params(ruby, "vector.dimension required"))?;
300
+ let coordinate_type_name = read_string(ruby, hash, "coordinateType")?
301
+ .ok_or_else(|| invalid_params(ruby, "vector.coordinateType required"))?;
302
+ let coordinate_type = VectorCoordinateType::parse(&coordinate_type_name).ok_or_else(|| {
303
+ invalid_params(
304
+ ruby,
305
+ format!("unknown vector coordinate type `{coordinate_type_name}`"),
306
+ )
307
+ })?;
308
+ let values_value = hash_get_either(ruby, hash, "values")
309
+ .ok_or_else(|| invalid_params(ruby, "vector.values required"))?;
310
+ let arr = RArray::try_convert(values_value)
311
+ .map_err(|_| invalid_params(ruby, "vector.values must be an Array"))?;
312
+
313
+ let mut raw = Vec::with_capacity(arr.len());
314
+ for item in arr.into_iter() {
315
+ if item.is_kind_of(ruby.class_true_class()) || item.is_kind_of(ruby.class_false_class()) {
316
+ return Err(invalid_params(
317
+ ruby,
318
+ "vector.values entries must be numeric",
319
+ ));
320
+ }
321
+ if let Ok(f) = Float::try_convert(item) {
322
+ let v = f.to_f64();
323
+ if !v.is_finite() {
324
+ return Err(invalid_params(
325
+ ruby,
326
+ "vector.values cannot be NaN or Infinity",
327
+ ));
328
+ }
329
+ raw.push(RawCoordinate::Float(v));
330
+ continue;
331
+ }
332
+ if let Ok(i) = Integer::try_convert(item) {
333
+ raw.push(RawCoordinate::Int(i.to_i64()?));
334
+ continue;
335
+ }
336
+ return Err(invalid_params(
337
+ ruby,
338
+ "vector.values entries must be numeric",
339
+ ));
340
+ }
341
+
342
+ let v = LoraVector::try_new(raw, dimension, coordinate_type)
343
+ .map_err(|e| invalid_params(ruby, e.to_string()))?;
344
+ Ok(LoraValue::Vector(v))
345
+ }
346
+
347
+ fn build_binary(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
348
+ let segments_value = hash_get_either(ruby, hash, "segments")
349
+ .ok_or_else(|| invalid_params(ruby, "binary.segments required"))?;
350
+ let arr = RArray::try_convert(segments_value)
351
+ .map_err(|_| invalid_params(ruby, "binary.segments must be an Array"))?;
352
+ let mut segments = Vec::with_capacity(arr.len());
353
+ for item in arr.into_iter() {
354
+ let segment = RString::try_convert(item)
355
+ .map_err(|_| invalid_params(ruby, "binary.segments entries must be Strings"))?;
356
+ segments.push(unsafe { segment.as_slice().to_vec() });
357
+ }
358
+ Ok(LoraValue::Binary(LoraBinary::from_segments(segments)))
359
+ }
360
+
361
+ fn read_i64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<i64>, MagnusError> {
362
+ let Some(v) = hash_get_either(ruby, hash, key) else {
363
+ return Ok(None);
364
+ };
365
+ Ok(Some(Integer::try_convert(v)?.to_i64().map_err(|_| {
366
+ invalid_params(ruby, format!("{key} out of i64 range"))
367
+ })?))
368
+ }
369
+
370
+ // ---- Hash accessors that accept either string or symbol keys ------------
371
+
372
+ pub(crate) fn hash_get_either(ruby: &Ruby, hash: RHash, key: &str) -> Option<Value> {
373
+ if let Some(v) = hash.get(ruby.str_new(key)) {
374
+ return Some(v);
375
+ }
376
+ hash.get(ruby.to_symbol(key))
377
+ }
378
+
379
+ pub(crate) fn hash_get_any(ruby: &Ruby, hash: RHash, keys: &[&str]) -> Option<Value> {
380
+ keys.iter().find_map(|key| hash_get_either(ruby, hash, key))
381
+ }
382
+
383
+ pub(crate) fn read_nonnegative_u64(ruby: &Ruby, value: Value) -> Result<u64, MagnusError> {
384
+ let n = Integer::try_convert(value)?.to_i64()?;
385
+ u64::try_from(n).map_err(|_| invalid_params(ruby, "option integer must be non-negative"))
386
+ }
387
+
388
+ fn read_string(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<String>, MagnusError> {
389
+ let Some(v) = hash_get_either(ruby, hash, key) else {
390
+ return Ok(None);
391
+ };
392
+ let s = RString::try_convert(v)?.to_string()?;
393
+ Ok(Some(s))
394
+ }
395
+
396
+ fn read_u32(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<u32>, MagnusError> {
397
+ let Some(v) = hash_get_either(ruby, hash, key) else {
398
+ return Ok(None);
399
+ };
400
+ let n = Integer::try_convert(v)?.to_i64()?;
401
+ u32::try_from(n)
402
+ .map(Some)
403
+ .map_err(|_| invalid_params(ruby, "srid out of u32 range"))
404
+ }
405
+
406
+ fn read_f64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<f64>, MagnusError> {
407
+ let Some(v) = hash_get_either(ruby, hash, key) else {
408
+ return Ok(None);
409
+ };
410
+ // Accept either Float or Integer — `cartesian(1, 2)` passing ints
411
+ // shouldn't force the caller to call `.to_f` first.
412
+ if let Ok(f) = Float::try_convert(v) {
413
+ return Ok(Some(f.to_f64()));
414
+ }
415
+ if let Ok(i) = Integer::try_convert(v) {
416
+ return Ok(Some(i.to_i64()? as f64));
417
+ }
418
+ Ok(None)
419
+ }
data/src/gvl.rs ADDED
@@ -0,0 +1,55 @@
1
+ //! Global VM Lock release primitive.
2
+
3
+ use std::ffi::c_void;
4
+ use std::mem::MaybeUninit;
5
+
6
+ /// Run `f` with Ruby's Global VM Lock released.
7
+ ///
8
+ /// Semantics match `rb_thread_call_without_gvl` — other Ruby threads can
9
+ /// progress while `f` runs. The closure MUST NOT touch Ruby state (no
10
+ /// `Value`s, no allocations into the Ruby heap), which we arrange by
11
+ /// keeping all such work on the calling thread. Everything inside
12
+ /// `database_execute`'s closure is pure Rust on pre-extracted data, so
13
+ /// this is sound.
14
+ pub(crate) fn without_gvl<F, R>(f: F) -> R
15
+ where
16
+ F: FnOnce() -> R,
17
+ F: Send,
18
+ R: Send,
19
+ {
20
+ struct Data<F, R> {
21
+ func: Option<F>,
22
+ result: MaybeUninit<R>,
23
+ }
24
+
25
+ unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
26
+ where
27
+ F: FnOnce() -> R,
28
+ {
29
+ let data = &mut *(data as *mut Data<F, R>);
30
+ let f = data
31
+ .func
32
+ .take()
33
+ .expect("without_gvl: closure already taken");
34
+ data.result.write(f());
35
+ std::ptr::null_mut()
36
+ }
37
+
38
+ let mut data = Data::<F, R> {
39
+ func: Some(f),
40
+ result: MaybeUninit::uninit(),
41
+ };
42
+
43
+ unsafe {
44
+ rb_sys::rb_thread_call_without_gvl(
45
+ Some(trampoline::<F, R>),
46
+ &mut data as *mut _ as *mut c_void,
47
+ // No unblock function — the engine doesn't implement
48
+ // cooperative cancellation, and a forced longjmp out of a
49
+ // mutex-holding section would be worse than waiting.
50
+ None,
51
+ std::ptr::null_mut(),
52
+ );
53
+ data.result.assume_init()
54
+ }
55
+ }