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 +4 -4
- data/README.md +38 -6
- data/extconf.rb +1 -1
- data/lib/lora_ruby/version.rb +1 -1
- data/src/errors.rs +74 -0
- data/src/from_ruby.rs +419 -0
- data/src/gvl.rs +55 -0
- data/src/lib.rs +63 -658
- data/src/to_ruby.rs +243 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8997fb6d383285f5875b2fcd1df32e6299ece787a9c3fdc5bf48327ec68c543
|
|
4
|
+
data.tar.gz: 2c319c8b8585c5a66c13ed5c37a3fc28a77e2bb5422d62011389d62358cbefbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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](
|
|
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.
|
|
178
|
-
|
|
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
|
|
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
|
data/lib/lora_ruby/version.rb
CHANGED
|
@@ -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.
|
|
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
|
+
}
|