lora-ruby 0.5.5 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5ba0aa4fc27617800ec4053cabef91152e7aeb369c37671b5b42c8b63262191
4
- data.tar.gz: 68db7428ccf49b03dddf70bfe430c92388269060653541819c19bcebd120db38
3
+ metadata.gz: f3a9450cc524f6ddb0431cab4516dcf68c064a8b6a489e9a920284976b07c624
4
+ data.tar.gz: 132681134206f3fc223cb2238079f2bbc691b201eaeca4771950fdc59788f72d
5
5
  SHA512:
6
- metadata.gz: c25ead41256e9c1a7db8b9842f161d5c9efad153dc52da52323381d5eb380a9f81d616f5dba8bcc9f1e0bbca4a5010f5b57a6f0e09c048cc4ac0367bc30384a0
7
- data.tar.gz: 891572a4890c93d4af1f92020e623724e3a7509340f29d6985f7a106e3e4ec0085c92aa08b98301219e4ca75cd45fe2062945bf51fe62f58c058ce5fd03dd08e
6
+ metadata.gz: e08270ea4b1946e2d86bd44ffc40860c12e3c8b1814a8aff02762934c0b0a836a2a4acfc01b2953082f07b863ff21836eb9f2ffb20872c3a8dcf972afd25c3c2
7
+ data.tar.gz: 604298f36756dd70d2ccce2072396db1b508b39a2c1413f2d3a660648be4131e011934314c9d1f260baa7a88be5b012f34feb2cbf318a2261176cde0eb19d39b
data/README.md CHANGED
@@ -76,8 +76,9 @@ binding's `lora_python.Database` and because it mirrors the
76
76
  ## Public API
77
77
 
78
78
  ```ruby
79
- LoraRuby::Database.create(wal_dir = nil) # -> Database
80
- LoraRuby::Database.new(wal_dir = nil) # -> Database (alias of .create)
79
+ LoraRuby::Database.create(database_name = nil, options = nil) # -> Database
80
+ LoraRuby::Database.new(database_name = nil, options = nil) # -> Database
81
+ LoraRuby::Database.open_wal(wal_dir, options = nil) # -> Database
81
82
 
82
83
  db.execute(query, params = nil) # -> { "columns" => [...], "rows" => [...] }
83
84
  db.clear # -> nil
@@ -156,9 +157,19 @@ replays committed writes before returning the handle.
156
157
  Call `db.close` before reopening the same archive inside one
157
158
  process.
158
159
 
159
- This first Ruby persistence slice intentionally stays small: the
160
- binding exposes archive-backed initialization plus the existing snapshot
161
- APIs, but not checkpoint, truncate, status, or sync-mode controls.
160
+ For explicit WAL directories with managed snapshots, use `open_wal`:
161
+
162
+ ```ruby
163
+ db = LoraRuby::Database.open_wal(
164
+ "./data/wal",
165
+ snapshot_dir: "./data/snapshots",
166
+ snapshot_every_commits: 1000,
167
+ snapshot_keep_old: 2,
168
+ )
169
+ ```
170
+
171
+ `snapshot_options` accepts the same compression/encryption options as
172
+ `save_snapshot`.
162
173
 
163
174
  ## Concurrency (GVL release)
164
175
 
@@ -7,7 +7,7 @@ module LoraRuby
7
7
  # - Scalars pass through as Ruby natives (`nil`, `true`, `false`,
8
8
  # `Integer`, `Float`, `String`).
9
9
  # - Lists and maps come back as `Array` / `Hash` (string keys).
10
- # - Graph, temporal, and spatial values come back as plain `Hash`es
10
+ # - Graph, temporal, spatial, vector, and binary values come back as plain `Hash`es
11
11
  # with a `"kind"` discriminator.
12
12
  #
13
13
  # If you want to narrow a value explicitly, use the `node?` / `point?`
@@ -56,6 +56,21 @@ module LoraRuby
56
56
  }
57
57
  end
58
58
 
59
+ # ------------------------------------------------------------------
60
+ # Binary / blob constructor — segmented bytes. The native extension
61
+ # accepts each segment as a Ruby String and preserves segment
62
+ # boundaries in WAL/snapshot storage.
63
+ # ------------------------------------------------------------------
64
+
65
+ def binary(segments)
66
+ copied = segments.map { |segment| segment.b.dup }
67
+ {
68
+ "kind" => "binary",
69
+ "length" => copied.sum(&:bytesize),
70
+ "segments" => copied,
71
+ }
72
+ end
73
+
59
74
  # ------------------------------------------------------------------
60
75
  # Spatial constructors — mirrors lora_python.cartesian / wgs84.
61
76
  # `cartesian(1, 2)` returns a 2D cartesian point; use `cartesian_3d`
@@ -122,6 +137,7 @@ module LoraRuby
122
137
  def path?(v) = tagged?(v, "path")
123
138
  def point?(v) = tagged?(v, "point")
124
139
  def vector?(v) = tagged?(v, "vector")
140
+ def binary?(v) = tagged?(v, "binary")
125
141
 
126
142
  def temporal?(v)
127
143
  return false unless v.is_a?(Hash)
@@ -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.5.5" unless const_defined?(:VERSION)
13
+ VERSION = "0.6.0" unless const_defined?(:VERSION)
14
14
  end
data/lib/lora_ruby.rb CHANGED
@@ -32,8 +32,8 @@ module LoraRuby
32
32
  %i[
33
33
  date time localtime datetime localdatetime duration
34
34
  cartesian cartesian_3d wgs84 wgs84_3d
35
- vector
36
- node? relationship? path? point? temporal? vector?
35
+ vector binary
36
+ node? relationship? path? point? temporal? vector? binary?
37
37
  ].each do |m|
38
38
  define_singleton_method(m) do |*args, **kwargs|
39
39
  if kwargs.empty?
data/src/lib.rs CHANGED
@@ -25,11 +25,11 @@ use magnus::{
25
25
 
26
26
  use lora_database::{
27
27
  Database as InnerDatabase, DatabaseOpenOptions, ExecuteOptions, InMemoryGraph, LoraValue,
28
- QueryResult, ResultFormat,
28
+ QueryResult, ResultFormat, SnapshotConfig, SnapshotOptions, WalConfig,
29
29
  };
30
30
  use lora_store::{
31
- LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint, LoraTime,
32
- LoraVector, RawCoordinate, VectorCoordinateType, VectorValues,
31
+ LoraBinary, LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint,
32
+ LoraTime, LoraVector, RawCoordinate, VectorCoordinateType, VectorValues,
33
33
  };
34
34
 
35
35
  // ============================================================================
@@ -63,6 +63,7 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
63
63
  let database = lora_ruby.define_class("Database", ruby.class_object())?;
64
64
  database.define_singleton_method("create", function!(database_create, -1))?;
65
65
  database.define_singleton_method("new", function!(database_new, -1))?;
66
+ database.define_singleton_method("open_wal", function!(database_open_wal, -1))?;
66
67
  database.define_method("execute", method!(database_execute, -1))?;
67
68
  database.define_method("clear", method!(database_clear, 0))?;
68
69
  database.define_method("close", method!(database_close, 0))?;
@@ -73,8 +74,8 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
73
74
  )?;
74
75
  database.define_method("inspect", method!(database_inspect, 0))?;
75
76
  database.define_method("to_s", method!(database_inspect, 0))?;
76
- database.define_method("save_snapshot", method!(database_save_snapshot, 1))?;
77
- database.define_method("load_snapshot", method!(database_load_snapshot, 1))?;
77
+ database.define_method("save_snapshot", method!(database_save_snapshot, -1))?;
78
+ database.define_method("load_snapshot", method!(database_load_snapshot, -1))?;
78
79
 
79
80
  // `LoraRuby::VERSION` is owned by `lib/lora_ruby/version.rb` so the
80
81
  // gem can expose a version before the native extension compiles
@@ -126,6 +127,34 @@ struct Database {
126
127
  db: Mutex<Option<Arc<InnerDatabase<InMemoryGraph>>>>,
127
128
  }
128
129
 
130
+ #[derive(Default)]
131
+ struct RubyDatabaseOpenOptions {
132
+ named: DatabaseOpenOptions,
133
+ has_database_dir: bool,
134
+ wal_dir: Option<String>,
135
+ snapshot_dir: Option<String>,
136
+ snapshot_every_commits: Option<u64>,
137
+ snapshot_keep_old: Option<usize>,
138
+ has_snapshot_codec: bool,
139
+ snapshot_codec: SnapshotOptions,
140
+ }
141
+
142
+ impl RubyDatabaseOpenOptions {
143
+ fn has_explicit_wal_options(&self) -> bool {
144
+ self.wal_dir.is_some()
145
+ || self.snapshot_dir.is_some()
146
+ || self.snapshot_every_commits.is_some()
147
+ || self.snapshot_keep_old.is_some()
148
+ || self.has_snapshot_codec
149
+ }
150
+
151
+ fn has_snapshot_tuning_options(&self) -> bool {
152
+ self.snapshot_every_commits.is_some()
153
+ || self.snapshot_keep_old.is_some()
154
+ || self.has_snapshot_codec
155
+ }
156
+ }
157
+
129
158
  impl Database {
130
159
  fn from_db(db: Arc<InnerDatabase<InMemoryGraph>>) -> Self {
131
160
  Self {
@@ -148,6 +177,35 @@ fn database_create(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError>
148
177
  database_new(ruby, args)
149
178
  }
150
179
 
180
+ fn database_open_wal(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
181
+ let (wal_dir, mut options) = match args.len() {
182
+ 1 | 2 => {
183
+ let wal_dir = RString::try_convert(args[0])?.to_string()?;
184
+ let options = if args.len() == 2 {
185
+ ruby_database_open_options(ruby, RHash::try_convert(args[1])?)?
186
+ } else {
187
+ RubyDatabaseOpenOptions::default()
188
+ };
189
+ (wal_dir, options)
190
+ }
191
+ n => {
192
+ return Err(MagnusError::new(
193
+ ruby.exception_arg_error(),
194
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
195
+ ));
196
+ }
197
+ };
198
+ if options.wal_dir.is_some() {
199
+ return Err(invalid_params(
200
+ ruby,
201
+ "wal_dir must be passed as the first argument to open_wal",
202
+ ));
203
+ }
204
+ options.wal_dir = Some(wal_dir);
205
+ let db = without_gvl(move || open_wal_database(options)).map_err(|e| query_error(ruby, e))?;
206
+ Ok(Database::from_db(db))
207
+ }
208
+
151
209
  fn database_clear(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
152
210
  database_inner(ruby, rb_self)?.clear();
153
211
  Ok(())
@@ -189,11 +247,11 @@ fn database_inspect(rb_self: &Database) -> String {
189
247
  fn database_save_snapshot(
190
248
  ruby: &Ruby,
191
249
  rb_self: &Database,
192
- path: RString,
250
+ args: &[Value],
193
251
  ) -> Result<RHash, MagnusError> {
194
- let path = path.to_string()?;
252
+ let (path, options) = snapshot_file_args(ruby, args)?;
195
253
  let db = database_inner(ruby, rb_self)?;
196
- let meta = without_gvl(move || db.save_snapshot_to(&path))
254
+ let meta = without_gvl(move || db.save_snapshot_to_with_options(&path, &options))
197
255
  .map_err(|e| query_error(ruby, format!("{e}")))?;
198
256
  snapshot_meta_to_rhash(ruby, meta)
199
257
  }
@@ -201,15 +259,62 @@ fn database_save_snapshot(
201
259
  fn database_load_snapshot(
202
260
  ruby: &Ruby,
203
261
  rb_self: &Database,
204
- path: RString,
262
+ args: &[Value],
205
263
  ) -> Result<RHash, MagnusError> {
206
- let path = path.to_string()?;
264
+ let (path, credentials) = snapshot_load_file_args(ruby, args)?;
207
265
  let db = database_inner(ruby, rb_self)?;
208
- let meta = without_gvl(move || db.load_snapshot_from(&path))
209
- .map_err(|e| query_error(ruby, format!("{e}")))?;
266
+ let meta =
267
+ without_gvl(move || db.load_snapshot_from_with_credentials(&path, credentials.as_ref()))
268
+ .map_err(|e| query_error(ruby, format!("{e}")))?;
210
269
  snapshot_meta_to_rhash(ruby, meta)
211
270
  }
212
271
 
272
+ fn snapshot_file_args(
273
+ ruby: &Ruby,
274
+ args: &[Value],
275
+ ) -> Result<(String, lora_database::SnapshotOptions), MagnusError> {
276
+ match args.len() {
277
+ 1 | 2 => {
278
+ let path = RString::try_convert(args[0])?.to_string()?;
279
+ let json = if args.len() == 2 {
280
+ ruby_optional_to_json(ruby, args[1])?
281
+ } else {
282
+ None
283
+ };
284
+ let options = lora_database::snapshot_options_from_json(json)
285
+ .map_err(|e| invalid_params(ruby, format!("invalid snapshot options: {e}")))?;
286
+ Ok((path, options))
287
+ }
288
+ n => Err(MagnusError::new(
289
+ ruby.exception_arg_error(),
290
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
291
+ )),
292
+ }
293
+ }
294
+
295
+ fn snapshot_load_file_args(
296
+ ruby: &Ruby,
297
+ args: &[Value],
298
+ ) -> Result<(String, Option<lora_database::SnapshotCredentials>), MagnusError> {
299
+ match args.len() {
300
+ 1 | 2 => {
301
+ let path = RString::try_convert(args[0])?.to_string()?;
302
+ let json = if args.len() == 2 {
303
+ ruby_optional_to_json(ruby, args[1])?
304
+ } else {
305
+ None
306
+ };
307
+ let credentials = lora_database::snapshot_credentials_from_json(json)
308
+ .map_err(|e| invalid_params(ruby, format!("invalid snapshot credentials: {e}")))?;
309
+ Ok((path, credentials))
310
+ }
311
+ n => Err(MagnusError::new(
312
+ ruby.exception_arg_error(),
313
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
314
+ )),
315
+ }
316
+ }
317
+
213
318
  fn snapshot_meta_to_rhash(
214
319
  ruby: &Ruby,
215
320
  meta: lora_database::SnapshotMeta,
@@ -237,16 +342,18 @@ fn snapshot_meta_to_rhash(
237
342
  fn database_open_args(
238
343
  ruby: &Ruby,
239
344
  args: &[Value],
240
- ) -> Result<(Option<String>, DatabaseOpenOptions), MagnusError> {
345
+ ) -> Result<(Option<String>, RubyDatabaseOpenOptions), MagnusError> {
241
346
  match args.len() {
242
- 0 => Ok((None, DatabaseOpenOptions::default())),
347
+ 0 => Ok((None, RubyDatabaseOpenOptions::default())),
243
348
  1 => {
244
349
  if args[0].is_nil() {
245
- Ok((None, DatabaseOpenOptions::default()))
350
+ Ok((None, RubyDatabaseOpenOptions::default()))
351
+ } else if let Ok(hash) = RHash::try_convert(args[0]) {
352
+ Ok((None, ruby_database_open_options(ruby, hash)?))
246
353
  } else {
247
354
  Ok((
248
355
  Some(RString::try_convert(args[0])?.to_string()?),
249
- DatabaseOpenOptions::default(),
356
+ RubyDatabaseOpenOptions::default(),
250
357
  ))
251
358
  }
252
359
  }
@@ -256,13 +363,7 @@ fn database_open_args(
256
363
  } else {
257
364
  Some(RString::try_convert(args[0])?.to_string()?)
258
365
  };
259
- let mut options = DatabaseOpenOptions::default();
260
- let hash = RHash::try_convert(args[1])?;
261
- if let Some(dir) = hash_get_either(ruby, hash, "database_dir")
262
- .or_else(|| hash_get_either(ruby, hash, "databaseDir"))
263
- {
264
- options.database_dir = RString::try_convert(dir)?.to_string()?.into();
265
- }
366
+ let options = ruby_database_open_options(ruby, RHash::try_convert(args[1])?)?;
266
367
  Ok((database_name, options))
267
368
  }
268
369
  n => Err(MagnusError::new(
@@ -272,13 +373,94 @@ fn database_open_args(
272
373
  }
273
374
  }
274
375
 
376
+ fn ruby_database_open_options(
377
+ ruby: &Ruby,
378
+ hash: RHash,
379
+ ) -> Result<RubyDatabaseOpenOptions, MagnusError> {
380
+ let mut options = RubyDatabaseOpenOptions::default();
381
+ if let Some(dir) = hash_get_any(ruby, hash, &["database_dir", "databaseDir"]) {
382
+ options.named.database_dir = RString::try_convert(dir)?.to_string()?.into();
383
+ options.has_database_dir = true;
384
+ }
385
+ if let Some(dir) = hash_get_any(ruby, hash, &["wal_dir", "walDir"]) {
386
+ options.wal_dir = Some(RString::try_convert(dir)?.to_string()?);
387
+ }
388
+ if let Some(dir) = hash_get_any(ruby, hash, &["snapshot_dir", "snapshotDir"]) {
389
+ options.snapshot_dir = Some(RString::try_convert(dir)?.to_string()?);
390
+ }
391
+ if let Some(value) = hash_get_any(
392
+ ruby,
393
+ hash,
394
+ &["snapshot_every_commits", "snapshotEveryCommits"],
395
+ ) {
396
+ options.snapshot_every_commits = Some(read_nonnegative_u64(ruby, value)?);
397
+ }
398
+ if let Some(value) = hash_get_any(ruby, hash, &["snapshot_keep_old", "snapshotKeepOld"]) {
399
+ let keep_old = read_nonnegative_u64(ruby, value)?;
400
+ options.snapshot_keep_old = Some(
401
+ usize::try_from(keep_old)
402
+ .map_err(|_| invalid_params(ruby, "snapshot_keep_old does not fit in usize"))?,
403
+ );
404
+ }
405
+ if let Some(value) = hash_get_any(ruby, hash, &["snapshot_options", "snapshotOptions"]) {
406
+ let json = ruby_optional_to_json(ruby, value)?;
407
+ options.has_snapshot_codec = true;
408
+ options.snapshot_codec = lora_database::snapshot_options_from_json(json)
409
+ .map_err(|e| invalid_params(ruby, format!("invalid snapshot options: {e}")))?;
410
+ }
411
+ Ok(options)
412
+ }
413
+
275
414
  fn open_database(
276
415
  database_name: Option<String>,
277
- options: DatabaseOpenOptions,
416
+ options: RubyDatabaseOpenOptions,
278
417
  ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, String> {
418
+ if options.has_explicit_wal_options() {
419
+ return Err(
420
+ "wal_dir/snapshot_dir are not valid for Database.create; use Database.open_wal"
421
+ .to_string(),
422
+ );
423
+ }
279
424
  let db = match database_name {
280
- Some(name) => InnerDatabase::open_named(name, options).map_err(|e| e.to_string())?,
281
- None => InnerDatabase::in_memory(),
425
+ Some(name) => InnerDatabase::open_named(name, options.named).map_err(|e| e.to_string())?,
426
+ None => {
427
+ if options.has_database_dir {
428
+ return Err("database_name is required when database_dir is provided".to_string());
429
+ }
430
+ InnerDatabase::in_memory()
431
+ }
432
+ };
433
+ Ok(Arc::new(db))
434
+ }
435
+
436
+ fn open_wal_database(
437
+ options: RubyDatabaseOpenOptions,
438
+ ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, String> {
439
+ if options.has_database_dir {
440
+ return Err("database_dir is not valid for Database.open_wal".to_string());
441
+ }
442
+ let has_snapshot_tuning = options.has_snapshot_tuning_options();
443
+ if options.snapshot_dir.is_none() && has_snapshot_tuning {
444
+ return Err(
445
+ "snapshot_dir is required when managed snapshot options are provided".to_string(),
446
+ );
447
+ }
448
+ let wal_dir = options
449
+ .wal_dir
450
+ .ok_or_else(|| "wal_dir is required for Database.open_wal".to_string())?;
451
+ let wal_config = WalConfig::enabled(wal_dir);
452
+ let db = if let Some(snapshot_dir) = options.snapshot_dir {
453
+ let mut snapshots = SnapshotConfig::enabled(snapshot_dir)
454
+ .keep_old(options.snapshot_keep_old.unwrap_or(1))
455
+ .codec(options.snapshot_codec);
456
+ if let Some(every) = options.snapshot_every_commits {
457
+ if every != 0 {
458
+ snapshots = snapshots.every_commits(every);
459
+ }
460
+ }
461
+ InnerDatabase::open_with_wal_snapshots(wal_config, snapshots).map_err(|e| e.to_string())?
462
+ } else {
463
+ InnerDatabase::open_with_wal(wal_config).map_err(|e| e.to_string())?
282
464
  };
283
465
  Ok(Arc::new(db))
284
466
  }
@@ -432,9 +614,25 @@ fn lora_value_to_ruby(ruby: &Ruby, value: &LoraValue) -> Result<Value, MagnusErr
432
614
  LoraValue::Duration(v) => tagged_iso(ruby, "duration", v.to_string()),
433
615
  LoraValue::Point(p) => point_to_ruby(ruby, p),
434
616
  LoraValue::Vector(v) => vector_to_ruby(ruby, v),
617
+ LoraValue::Binary(v) => binary_to_ruby(ruby, v),
435
618
  }
436
619
  }
437
620
 
621
+ fn binary_to_ruby(ruby: &Ruby, value: &LoraBinary) -> Result<Value, MagnusError> {
622
+ let h = ruby.hash_new();
623
+ h.aset(ruby.str_new("kind"), ruby.str_new("binary"))?;
624
+ h.aset(
625
+ ruby.str_new("length"),
626
+ ruby.integer_from_i64(value.len() as i64),
627
+ )?;
628
+ let segments = ruby.ary_new();
629
+ for segment in value.segments() {
630
+ segments.push(ruby.str_from_slice(segment))?;
631
+ }
632
+ h.aset(ruby.str_new("segments"), segments)?;
633
+ Ok(h.as_value())
634
+ }
635
+
438
636
  fn vector_to_ruby(ruby: &Ruby, v: &LoraVector) -> Result<Value, MagnusError> {
439
637
  let h = ruby.hash_new();
440
638
  h.aset(ruby.str_new("kind"), ruby.str_new("vector"))?;
@@ -573,6 +771,86 @@ fn coerce_key(ruby: &Ruby, v: Value) -> Result<String, MagnusError> {
573
771
  Err(invalid_params(ruby, "param keys must be String or Symbol"))
574
772
  }
575
773
 
774
+ fn ruby_optional_to_json(
775
+ ruby: &Ruby,
776
+ value: Value,
777
+ ) -> Result<Option<serde_json::Value>, MagnusError> {
778
+ if value.is_nil() {
779
+ Ok(None)
780
+ } else {
781
+ ruby_value_to_json(ruby, value).map(Some)
782
+ }
783
+ }
784
+
785
+ fn ruby_value_to_json(ruby: &Ruby, value: Value) -> Result<serde_json::Value, MagnusError> {
786
+ if value.is_nil() {
787
+ return Ok(serde_json::Value::Null);
788
+ }
789
+ if value.is_kind_of(ruby.class_true_class()) {
790
+ return Ok(serde_json::Value::Bool(true));
791
+ }
792
+ if value.is_kind_of(ruby.class_false_class()) {
793
+ return Ok(serde_json::Value::Bool(false));
794
+ }
795
+ if let Ok(i) = Integer::try_convert(value) {
796
+ let n = i
797
+ .to_i64()
798
+ .map_err(|_| invalid_params(ruby, "snapshot option integer does not fit in i64"))?;
799
+ return Ok(serde_json::Value::Number(n.into()));
800
+ }
801
+ if let Ok(f) = Float::try_convert(value) {
802
+ let Some(number) = serde_json::Number::from_f64(f.to_f64()) else {
803
+ return Err(invalid_params(ruby, "snapshot option float must be finite"));
804
+ };
805
+ return Ok(serde_json::Value::Number(number));
806
+ }
807
+ if let Ok(s) = RString::try_convert(value) {
808
+ return Ok(serde_json::Value::String(s.to_string()?));
809
+ }
810
+ if let Ok(sym) = Symbol::try_convert(value) {
811
+ return Ok(serde_json::Value::String(sym.name()?.into_owned()));
812
+ }
813
+ if let Ok(arr) = RArray::try_convert(value) {
814
+ let mut out = Vec::with_capacity(arr.len());
815
+ for item in arr.into_iter() {
816
+ out.push(ruby_value_to_json(ruby, item)?);
817
+ }
818
+ return Ok(serde_json::Value::Array(out));
819
+ }
820
+ if let Ok(hash) = RHash::try_convert(value) {
821
+ let mut out = serde_json::Map::new();
822
+ let mut error = None;
823
+ hash.foreach(|k: Value, v: Value| {
824
+ let key = match coerce_key(ruby, k) {
825
+ Ok(key) => key,
826
+ Err(e) => {
827
+ error = Some(e);
828
+ return Ok(ForEach::Stop);
829
+ }
830
+ };
831
+ let json = match ruby_value_to_json(ruby, v) {
832
+ Ok(json) => json,
833
+ Err(e) => {
834
+ error = Some(e);
835
+ return Ok(ForEach::Stop);
836
+ }
837
+ };
838
+ out.insert(key, json);
839
+ Ok(ForEach::Continue)
840
+ })?;
841
+ if let Some(error) = error {
842
+ return Err(error);
843
+ }
844
+ return Ok(serde_json::Value::Object(out));
845
+ }
846
+
847
+ let class_name = unsafe { value.classname() }.into_owned();
848
+ Err(invalid_params(
849
+ ruby,
850
+ format!("unsupported snapshot option type: {class_name}"),
851
+ ))
852
+ }
853
+
576
854
  fn ruby_value_to_lora(ruby: &Ruby, v: Value) -> Result<LoraValue, MagnusError> {
577
855
  if v.is_nil() {
578
856
  return Ok(LoraValue::Null);
@@ -665,6 +943,7 @@ fn ruby_hash_to_cypher(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusErro
665
943
  }
666
944
  "point" => return build_point(ruby, hash),
667
945
  "vector" => return build_vector(ruby, hash),
946
+ "binary" | "blob" => return build_binary(ruby, hash),
668
947
  _ => { /* fall through to plain-map handling */ }
669
948
  }
670
949
  }
@@ -765,6 +1044,20 @@ fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
765
1044
  Ok(LoraValue::Vector(v))
766
1045
  }
767
1046
 
1047
+ fn build_binary(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
1048
+ let segments_value = hash_get_either(ruby, hash, "segments")
1049
+ .ok_or_else(|| invalid_params(ruby, "binary.segments required"))?;
1050
+ let arr = RArray::try_convert(segments_value)
1051
+ .map_err(|_| invalid_params(ruby, "binary.segments must be an Array"))?;
1052
+ let mut segments = Vec::with_capacity(arr.len());
1053
+ for item in arr.into_iter() {
1054
+ let segment = RString::try_convert(item)
1055
+ .map_err(|_| invalid_params(ruby, "binary.segments entries must be Strings"))?;
1056
+ segments.push(unsafe { segment.as_slice().to_vec() });
1057
+ }
1058
+ Ok(LoraValue::Binary(LoraBinary::from_segments(segments)))
1059
+ }
1060
+
768
1061
  fn read_i64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<i64>, MagnusError> {
769
1062
  let Some(v) = hash_get_either(ruby, hash, key) else {
770
1063
  return Ok(None);
@@ -783,6 +1076,15 @@ fn hash_get_either(ruby: &Ruby, hash: RHash, key: &str) -> Option<Value> {
783
1076
  hash.get(ruby.to_symbol(key))
784
1077
  }
785
1078
 
1079
+ fn hash_get_any(ruby: &Ruby, hash: RHash, keys: &[&str]) -> Option<Value> {
1080
+ keys.iter().find_map(|key| hash_get_either(ruby, hash, key))
1081
+ }
1082
+
1083
+ fn read_nonnegative_u64(ruby: &Ruby, value: Value) -> Result<u64, MagnusError> {
1084
+ let n = Integer::try_convert(value)?.to_i64()?;
1085
+ u64::try_from(n).map_err(|_| invalid_params(ruby, "option integer must be non-negative"))
1086
+ }
1087
+
786
1088
  fn read_string(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<String>, MagnusError> {
787
1089
  let Some(v) = hash_get_either(ruby, hash, key) else {
788
1090
  return Ok(None);
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lora-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.5
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LoraDB, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-27 00:00:00.000000000 Z
11
+ date: 2026-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys