lora-ruby 0.5.6 → 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.
data/src/lib.rs CHANGED
@@ -14,24 +14,28 @@
14
14
  //! tagged `Hash`es (string keys) with a `"kind"` discriminator.
15
15
 
16
16
  use std::collections::BTreeMap;
17
- use std::ffi::c_void;
18
- use std::mem::MaybeUninit;
19
17
  use std::sync::{Arc, Mutex};
20
18
 
21
19
  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,
20
+ function, method, prelude::*, value::ReprValue, Error as MagnusError, RHash, RString, Ruby,
21
+ Value,
24
22
  };
25
23
 
26
24
  use lora_database::{
27
- Database as InnerDatabase, DatabaseOpenOptions, ExecuteOptions, InMemoryGraph, LoraValue,
28
- QueryResult, ResultFormat,
29
- };
30
- use lora_store::{
31
- LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint, LoraTime,
32
- LoraVector, RawCoordinate, VectorCoordinateType, VectorValues,
25
+ Database as InnerDatabase, DatabaseOpenOptions, ExecuteOptions, InMemoryGraph, QueryResult,
26
+ ResultFormat, SnapshotConfig, SnapshotOptions, WalConfig,
33
27
  };
34
28
 
29
+ mod errors;
30
+ mod from_ruby;
31
+ mod gvl;
32
+ mod to_ruby;
33
+
34
+ use errors::{invalid_params, query_error, query_error_from_anyhow};
35
+ use from_ruby::{hash_get_any, read_nonnegative_u64, ruby_optional_to_json, ruby_value_to_params};
36
+ use gvl::without_gvl;
37
+ use to_ruby::{lora_value_to_ruby, query_plan_to_ruby, query_profile_to_ruby};
38
+
35
39
  // ============================================================================
36
40
  // Module / exception registration
37
41
  // ============================================================================
@@ -63,7 +67,10 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
63
67
  let database = lora_ruby.define_class("Database", ruby.class_object())?;
64
68
  database.define_singleton_method("create", function!(database_create, -1))?;
65
69
  database.define_singleton_method("new", function!(database_new, -1))?;
70
+ database.define_singleton_method("open_wal", function!(database_open_wal, -1))?;
66
71
  database.define_method("execute", method!(database_execute, -1))?;
72
+ database.define_method("explain", method!(database_explain, -1))?;
73
+ database.define_method("profile", method!(database_profile, -1))?;
67
74
  database.define_method("clear", method!(database_clear, 0))?;
68
75
  database.define_method("close", method!(database_close, 0))?;
69
76
  database.define_method("node_count", method!(database_node_count, 0))?;
@@ -73,8 +80,8 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
73
80
  )?;
74
81
  database.define_method("inspect", method!(database_inspect, 0))?;
75
82
  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))?;
83
+ database.define_method("save_snapshot", method!(database_save_snapshot, -1))?;
84
+ database.define_method("load_snapshot", method!(database_load_snapshot, -1))?;
78
85
 
79
86
  // `LoraRuby::VERSION` is owned by `lib/lora_ruby/version.rb` so the
80
87
  // gem can expose a version before the native extension compiles
@@ -84,34 +91,6 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
84
91
  Ok(())
85
92
  }
86
93
 
87
- // ============================================================================
88
- // Error lookups
89
- // ============================================================================
90
-
91
- fn lora_module(ruby: &Ruby) -> RModule {
92
- ruby.class_object()
93
- .const_get::<_, RModule>("LoraRuby")
94
- .expect("LoraRuby module is defined by `init` before any method runs")
95
- }
96
-
97
- fn lora_error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
98
- // `const_get::<_, ExceptionClass>` converts the stored RClass into
99
- // an ExceptionClass — this is the sound path, because our subclasses
100
- // of StandardError retain the exception-class trait on the Ruby
101
- // side even though `define_class` typed them as RClass.
102
- lora_module(ruby)
103
- .const_get::<_, ExceptionClass>(name)
104
- .unwrap_or_else(|_| ruby.exception_standard_error())
105
- }
106
-
107
- fn query_error(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
108
- MagnusError::new(lora_error_class(ruby, "QueryError"), msg.into())
109
- }
110
-
111
- fn invalid_params(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
112
- MagnusError::new(lora_error_class(ruby, "InvalidParamsError"), msg.into())
113
- }
114
-
115
94
  // ============================================================================
116
95
  // Database
117
96
  // ============================================================================
@@ -126,6 +105,34 @@ struct Database {
126
105
  db: Mutex<Option<Arc<InnerDatabase<InMemoryGraph>>>>,
127
106
  }
128
107
 
108
+ #[derive(Default)]
109
+ struct RubyDatabaseOpenOptions {
110
+ named: DatabaseOpenOptions,
111
+ has_database_dir: bool,
112
+ wal_dir: Option<String>,
113
+ snapshot_dir: Option<String>,
114
+ snapshot_every_commits: Option<u64>,
115
+ snapshot_keep_old: Option<usize>,
116
+ has_snapshot_codec: bool,
117
+ snapshot_codec: SnapshotOptions,
118
+ }
119
+
120
+ impl RubyDatabaseOpenOptions {
121
+ fn has_explicit_wal_options(&self) -> bool {
122
+ self.wal_dir.is_some()
123
+ || self.snapshot_dir.is_some()
124
+ || self.snapshot_every_commits.is_some()
125
+ || self.snapshot_keep_old.is_some()
126
+ || self.has_snapshot_codec
127
+ }
128
+
129
+ fn has_snapshot_tuning_options(&self) -> bool {
130
+ self.snapshot_every_commits.is_some()
131
+ || self.snapshot_keep_old.is_some()
132
+ || self.has_snapshot_codec
133
+ }
134
+ }
135
+
129
136
  impl Database {
130
137
  fn from_db(db: Arc<InnerDatabase<InMemoryGraph>>) -> Self {
131
138
  Self {
@@ -148,6 +155,35 @@ fn database_create(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError>
148
155
  database_new(ruby, args)
149
156
  }
150
157
 
158
+ fn database_open_wal(ruby: &Ruby, args: &[Value]) -> Result<Database, MagnusError> {
159
+ let (wal_dir, mut options) = match args.len() {
160
+ 1 | 2 => {
161
+ let wal_dir = RString::try_convert(args[0])?.to_string()?;
162
+ let options = if args.len() == 2 {
163
+ ruby_database_open_options(ruby, RHash::try_convert(args[1])?)?
164
+ } else {
165
+ RubyDatabaseOpenOptions::default()
166
+ };
167
+ (wal_dir, options)
168
+ }
169
+ n => {
170
+ return Err(MagnusError::new(
171
+ ruby.exception_arg_error(),
172
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
173
+ ));
174
+ }
175
+ };
176
+ if options.wal_dir.is_some() {
177
+ return Err(invalid_params(
178
+ ruby,
179
+ "wal_dir must be passed as the first argument to open_wal",
180
+ ));
181
+ }
182
+ options.wal_dir = Some(wal_dir);
183
+ let db = without_gvl(move || open_wal_database(options)).map_err(|e| query_error(ruby, e))?;
184
+ Ok(Database::from_db(db))
185
+ }
186
+
151
187
  fn database_clear(ruby: &Ruby, rb_self: &Database) -> Result<(), MagnusError> {
152
188
  database_inner(ruby, rb_self)?.clear();
153
189
  Ok(())
@@ -189,27 +225,74 @@ fn database_inspect(rb_self: &Database) -> String {
189
225
  fn database_save_snapshot(
190
226
  ruby: &Ruby,
191
227
  rb_self: &Database,
192
- path: RString,
228
+ args: &[Value],
193
229
  ) -> Result<RHash, MagnusError> {
194
- let path = path.to_string()?;
230
+ let (path, options) = snapshot_file_args(ruby, args)?;
195
231
  let db = database_inner(ruby, rb_self)?;
196
- let meta = without_gvl(move || db.save_snapshot_to(&path))
197
- .map_err(|e| query_error(ruby, format!("{e}")))?;
232
+ let meta = without_gvl(move || db.save_snapshot_to_with_options(&path, &options))
233
+ .map_err(|e| query_error_from_anyhow(ruby, e))?;
198
234
  snapshot_meta_to_rhash(ruby, meta)
199
235
  }
200
236
 
201
237
  fn database_load_snapshot(
202
238
  ruby: &Ruby,
203
239
  rb_self: &Database,
204
- path: RString,
240
+ args: &[Value],
205
241
  ) -> Result<RHash, MagnusError> {
206
- let path = path.to_string()?;
242
+ let (path, credentials) = snapshot_load_file_args(ruby, args)?;
207
243
  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}")))?;
244
+ let meta =
245
+ without_gvl(move || db.load_snapshot_from_with_credentials(&path, credentials.as_ref()))
246
+ .map_err(|e| query_error_from_anyhow(ruby, e))?;
210
247
  snapshot_meta_to_rhash(ruby, meta)
211
248
  }
212
249
 
250
+ fn snapshot_file_args(
251
+ ruby: &Ruby,
252
+ args: &[Value],
253
+ ) -> Result<(String, SnapshotOptions), MagnusError> {
254
+ match args.len() {
255
+ 1 | 2 => {
256
+ let path = RString::try_convert(args[0])?.to_string()?;
257
+ let json = if args.len() == 2 {
258
+ ruby_optional_to_json(ruby, args[1])?
259
+ } else {
260
+ None
261
+ };
262
+ let options = lora_database::snapshot_options_from_json(json)
263
+ .map_err(|e| invalid_params(ruby, format!("invalid snapshot options: {e}")))?;
264
+ Ok((path, options))
265
+ }
266
+ n => Err(MagnusError::new(
267
+ ruby.exception_arg_error(),
268
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
269
+ )),
270
+ }
271
+ }
272
+
273
+ fn snapshot_load_file_args(
274
+ ruby: &Ruby,
275
+ args: &[Value],
276
+ ) -> Result<(String, Option<lora_database::SnapshotCredentials>), MagnusError> {
277
+ match args.len() {
278
+ 1 | 2 => {
279
+ let path = RString::try_convert(args[0])?.to_string()?;
280
+ let json = if args.len() == 2 {
281
+ ruby_optional_to_json(ruby, args[1])?
282
+ } else {
283
+ None
284
+ };
285
+ let credentials = lora_database::snapshot_credentials_from_json(json)
286
+ .map_err(|e| invalid_params(ruby, format!("invalid snapshot credentials: {e}")))?;
287
+ Ok((path, credentials))
288
+ }
289
+ n => Err(MagnusError::new(
290
+ ruby.exception_arg_error(),
291
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
292
+ )),
293
+ }
294
+ }
295
+
213
296
  fn snapshot_meta_to_rhash(
214
297
  ruby: &Ruby,
215
298
  meta: lora_database::SnapshotMeta,
@@ -237,16 +320,18 @@ fn snapshot_meta_to_rhash(
237
320
  fn database_open_args(
238
321
  ruby: &Ruby,
239
322
  args: &[Value],
240
- ) -> Result<(Option<String>, DatabaseOpenOptions), MagnusError> {
323
+ ) -> Result<(Option<String>, RubyDatabaseOpenOptions), MagnusError> {
241
324
  match args.len() {
242
- 0 => Ok((None, DatabaseOpenOptions::default())),
325
+ 0 => Ok((None, RubyDatabaseOpenOptions::default())),
243
326
  1 => {
244
327
  if args[0].is_nil() {
245
- Ok((None, DatabaseOpenOptions::default()))
328
+ Ok((None, RubyDatabaseOpenOptions::default()))
329
+ } else if let Ok(hash) = RHash::try_convert(args[0]) {
330
+ Ok((None, ruby_database_open_options(ruby, hash)?))
246
331
  } else {
247
332
  Ok((
248
333
  Some(RString::try_convert(args[0])?.to_string()?),
249
- DatabaseOpenOptions::default(),
334
+ RubyDatabaseOpenOptions::default(),
250
335
  ))
251
336
  }
252
337
  }
@@ -256,13 +341,7 @@ fn database_open_args(
256
341
  } else {
257
342
  Some(RString::try_convert(args[0])?.to_string()?)
258
343
  };
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
- }
344
+ let options = ruby_database_open_options(ruby, RHash::try_convert(args[1])?)?;
266
345
  Ok((database_name, options))
267
346
  }
268
347
  n => Err(MagnusError::new(
@@ -272,13 +351,94 @@ fn database_open_args(
272
351
  }
273
352
  }
274
353
 
354
+ fn ruby_database_open_options(
355
+ ruby: &Ruby,
356
+ hash: RHash,
357
+ ) -> Result<RubyDatabaseOpenOptions, MagnusError> {
358
+ let mut options = RubyDatabaseOpenOptions::default();
359
+ if let Some(dir) = hash_get_any(ruby, hash, &["database_dir", "databaseDir"]) {
360
+ options.named.database_dir = RString::try_convert(dir)?.to_string()?.into();
361
+ options.has_database_dir = true;
362
+ }
363
+ if let Some(dir) = hash_get_any(ruby, hash, &["wal_dir", "walDir"]) {
364
+ options.wal_dir = Some(RString::try_convert(dir)?.to_string()?);
365
+ }
366
+ if let Some(dir) = hash_get_any(ruby, hash, &["snapshot_dir", "snapshotDir"]) {
367
+ options.snapshot_dir = Some(RString::try_convert(dir)?.to_string()?);
368
+ }
369
+ if let Some(value) = hash_get_any(
370
+ ruby,
371
+ hash,
372
+ &["snapshot_every_commits", "snapshotEveryCommits"],
373
+ ) {
374
+ options.snapshot_every_commits = Some(read_nonnegative_u64(ruby, value)?);
375
+ }
376
+ if let Some(value) = hash_get_any(ruby, hash, &["snapshot_keep_old", "snapshotKeepOld"]) {
377
+ let keep_old = read_nonnegative_u64(ruby, value)?;
378
+ options.snapshot_keep_old = Some(
379
+ usize::try_from(keep_old)
380
+ .map_err(|_| invalid_params(ruby, "snapshot_keep_old does not fit in usize"))?,
381
+ );
382
+ }
383
+ if let Some(value) = hash_get_any(ruby, hash, &["snapshot_options", "snapshotOptions"]) {
384
+ let json = ruby_optional_to_json(ruby, value)?;
385
+ options.has_snapshot_codec = true;
386
+ options.snapshot_codec = lora_database::snapshot_options_from_json(json)
387
+ .map_err(|e| invalid_params(ruby, format!("invalid snapshot options: {e}")))?;
388
+ }
389
+ Ok(options)
390
+ }
391
+
275
392
  fn open_database(
276
393
  database_name: Option<String>,
277
- options: DatabaseOpenOptions,
394
+ options: RubyDatabaseOpenOptions,
278
395
  ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, String> {
396
+ if options.has_explicit_wal_options() {
397
+ return Err(
398
+ "wal_dir/snapshot_dir are not valid for Database.create; use Database.open_wal"
399
+ .to_string(),
400
+ );
401
+ }
279
402
  let db = match database_name {
280
- Some(name) => InnerDatabase::open_named(name, options).map_err(|e| e.to_string())?,
281
- None => InnerDatabase::in_memory(),
403
+ Some(name) => InnerDatabase::open_named(name, options.named).map_err(|e| e.to_string())?,
404
+ None => {
405
+ if options.has_database_dir {
406
+ return Err("database_name is required when database_dir is provided".to_string());
407
+ }
408
+ InnerDatabase::in_memory()
409
+ }
410
+ };
411
+ Ok(Arc::new(db))
412
+ }
413
+
414
+ fn open_wal_database(
415
+ options: RubyDatabaseOpenOptions,
416
+ ) -> Result<Arc<InnerDatabase<InMemoryGraph>>, String> {
417
+ if options.has_database_dir {
418
+ return Err("database_dir is not valid for Database.open_wal".to_string());
419
+ }
420
+ let has_snapshot_tuning = options.has_snapshot_tuning_options();
421
+ if options.snapshot_dir.is_none() && has_snapshot_tuning {
422
+ return Err(
423
+ "snapshot_dir is required when managed snapshot options are provided".to_string(),
424
+ );
425
+ }
426
+ let wal_dir = options
427
+ .wal_dir
428
+ .ok_or_else(|| "wal_dir is required for Database.open_wal".to_string())?;
429
+ let wal_config = WalConfig::enabled(wal_dir);
430
+ let db = if let Some(snapshot_dir) = options.snapshot_dir {
431
+ let mut snapshots = SnapshotConfig::enabled(snapshot_dir)
432
+ .keep_old(options.snapshot_keep_old.unwrap_or(1))
433
+ .codec(options.snapshot_codec);
434
+ if let Some(every) = options.snapshot_every_commits {
435
+ if every != 0 {
436
+ snapshots = snapshots.every_commits(every);
437
+ }
438
+ }
439
+ InnerDatabase::open_with_wal_snapshots(wal_config, snapshots).map_err(|e| e.to_string())?
440
+ } else {
441
+ InnerDatabase::open_with_wal(wal_config).map_err(|e| e.to_string())?
282
442
  };
283
443
  Ok(Arc::new(db))
284
444
  }
@@ -344,7 +504,7 @@ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
344
504
  let row_arrays = match exec_result {
345
505
  Ok(QueryResult::RowArrays(r)) => r,
346
506
  Ok(_) => return Err(query_error(ruby, "expected RowArrays result")),
347
- Err(e) => return Err(query_error(ruby, format!("{e}"))),
507
+ Err(e) => return Err(query_error_from_anyhow(ruby, e)),
348
508
  };
349
509
 
350
510
  let out = ruby.hash_new();
@@ -366,507 +526,54 @@ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
366
526
  Ok(out)
367
527
  }
368
528
 
369
- // ============================================================================
370
- // LoraValue Ruby
371
- // ============================================================================
372
-
373
- fn lora_value_to_ruby(ruby: &Ruby, value: &LoraValue) -> Result<Value, MagnusError> {
374
- match value {
375
- LoraValue::Null => Ok(ruby.qnil().as_value()),
376
- LoraValue::Bool(b) => Ok(if *b {
377
- ruby.qtrue().as_value()
378
- } else {
379
- ruby.qfalse().as_value()
380
- }),
381
- LoraValue::Int(i) => Ok(ruby.integer_from_i64(*i).as_value()),
382
- LoraValue::Float(f) => Ok(ruby.float_from_f64(*f).as_value()),
383
- LoraValue::String(s) => Ok(ruby.str_new(s).as_value()),
384
- LoraValue::List(items) => {
385
- let arr = ruby.ary_new();
386
- for item in items {
387
- arr.push(lora_value_to_ruby(ruby, item)?)?;
388
- }
389
- Ok(arr.as_value())
390
- }
391
- LoraValue::Map(m) => {
392
- let h = ruby.hash_new();
393
- for (k, v) in m {
394
- h.aset(ruby.str_new(k), lora_value_to_ruby(ruby, v)?)?;
395
- }
396
- Ok(h.as_value())
397
- }
398
- LoraValue::Node(id) => {
399
- let h = ruby.hash_new();
400
- h.aset(ruby.str_new("kind"), ruby.str_new("node"))?;
401
- h.aset(ruby.str_new("id"), ruby.integer_from_i64(*id as i64))?;
402
- h.aset(ruby.str_new("labels"), ruby.ary_new())?;
403
- h.aset(ruby.str_new("properties"), ruby.hash_new())?;
404
- Ok(h.as_value())
405
- }
406
- LoraValue::Relationship(id) => {
407
- let h = ruby.hash_new();
408
- h.aset(ruby.str_new("kind"), ruby.str_new("relationship"))?;
409
- h.aset(ruby.str_new("id"), ruby.integer_from_i64(*id as i64))?;
410
- Ok(h.as_value())
411
- }
412
- LoraValue::Path(p) => {
413
- let h = ruby.hash_new();
414
- h.aset(ruby.str_new("kind"), ruby.str_new("path"))?;
415
- let nodes = ruby.ary_new();
416
- for n in &p.nodes {
417
- nodes.push(ruby.integer_from_i64(*n as i64))?;
418
- }
419
- let rels = ruby.ary_new();
420
- for r in &p.rels {
421
- rels.push(ruby.integer_from_i64(*r as i64))?;
422
- }
423
- h.aset(ruby.str_new("nodes"), nodes)?;
424
- h.aset(ruby.str_new("rels"), rels)?;
425
- Ok(h.as_value())
426
- }
427
- LoraValue::Date(v) => tagged_iso(ruby, "date", v.to_string()),
428
- LoraValue::Time(v) => tagged_iso(ruby, "time", v.to_string()),
429
- LoraValue::LocalTime(v) => tagged_iso(ruby, "localtime", v.to_string()),
430
- LoraValue::DateTime(v) => tagged_iso(ruby, "datetime", v.to_string()),
431
- LoraValue::LocalDateTime(v) => tagged_iso(ruby, "localdatetime", v.to_string()),
432
- LoraValue::Duration(v) => tagged_iso(ruby, "duration", v.to_string()),
433
- LoraValue::Point(p) => point_to_ruby(ruby, p),
434
- LoraValue::Vector(v) => vector_to_ruby(ruby, v),
435
- }
436
- }
437
-
438
- fn vector_to_ruby(ruby: &Ruby, v: &LoraVector) -> Result<Value, MagnusError> {
439
- let h = ruby.hash_new();
440
- h.aset(ruby.str_new("kind"), ruby.str_new("vector"))?;
441
- h.aset(
442
- ruby.str_new("dimension"),
443
- ruby.integer_from_i64(v.dimension as i64),
444
- )?;
445
- h.aset(
446
- ruby.str_new("coordinateType"),
447
- ruby.str_new(v.coordinate_type().as_str()),
448
- )?;
449
-
450
- let values = ruby.ary_new();
451
- match &v.values {
452
- VectorValues::Float64(vs) => {
453
- for x in vs {
454
- values.push(ruby.float_from_f64(*x))?;
455
- }
456
- }
457
- VectorValues::Float32(vs) => {
458
- for x in vs {
459
- values.push(ruby.float_from_f64(*x as f64))?;
460
- }
461
- }
462
- VectorValues::Integer64(vs) => {
463
- for x in vs {
464
- values.push(ruby.integer_from_i64(*x))?;
465
- }
466
- }
467
- VectorValues::Integer32(vs) => {
468
- for x in vs {
469
- values.push(ruby.integer_from_i64(*x as i64))?;
470
- }
471
- }
472
- VectorValues::Integer16(vs) => {
473
- for x in vs {
474
- values.push(ruby.integer_from_i64(*x as i64))?;
475
- }
476
- }
477
- VectorValues::Integer8(vs) => {
478
- for x in vs {
479
- values.push(ruby.integer_from_i64(*x as i64))?;
480
- }
481
- }
482
- }
483
- h.aset(ruby.str_new("values"), values)?;
484
- Ok(h.as_value())
485
- }
486
-
487
- fn tagged_iso(ruby: &Ruby, kind: &str, iso: String) -> Result<Value, MagnusError> {
488
- let h = ruby.hash_new();
489
- h.aset(ruby.str_new("kind"), ruby.str_new(kind))?;
490
- h.aset(ruby.str_new("iso"), ruby.str_new(&iso))?;
491
- Ok(h.as_value())
492
- }
493
-
494
- /// Render a `LoraPoint` into the canonical external point shape — kept
495
- /// 1:1 aligned with the `LoraPoint` union emitted by `lora-node` /
496
- /// `lora-wasm` / `lora-python`.
497
- fn point_to_ruby(ruby: &Ruby, p: &LoraPoint) -> Result<Value, MagnusError> {
498
- let h = ruby.hash_new();
499
- h.aset(ruby.str_new("kind"), ruby.str_new("point"))?;
500
- h.aset(ruby.str_new("srid"), ruby.integer_from_i64(p.srid as i64))?;
501
- h.aset(ruby.str_new("crs"), ruby.str_new(p.crs_name()))?;
502
- h.aset(ruby.str_new("x"), ruby.float_from_f64(p.x))?;
503
- h.aset(ruby.str_new("y"), ruby.float_from_f64(p.y))?;
504
- if let Some(z) = p.z {
505
- h.aset(ruby.str_new("z"), ruby.float_from_f64(z))?;
506
- }
507
- if p.is_geographic() {
508
- h.aset(
509
- ruby.str_new("longitude"),
510
- ruby.float_from_f64(p.longitude()),
511
- )?;
512
- h.aset(ruby.str_new("latitude"), ruby.float_from_f64(p.latitude()))?;
513
- if let Some(height) = p.height() {
514
- h.aset(ruby.str_new("height"), ruby.float_from_f64(height))?;
515
- }
516
- }
517
- Ok(h.as_value())
518
- }
519
-
520
- // ============================================================================
521
- // Ruby → LoraValue (params)
522
- // ============================================================================
523
-
524
- fn ruby_value_to_params(
525
- ruby: &Ruby,
526
- value: Value,
527
- ) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
528
- let hash = RHash::try_convert(value)
529
- .map_err(|_| invalid_params(ruby, "params must be a Hash keyed by parameter name"))?;
530
- hash_to_string_map(ruby, hash)
531
- }
532
-
533
- fn hash_to_string_map(
534
- ruby: &Ruby,
535
- hash: RHash,
536
- ) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
537
- let mut out = BTreeMap::new();
538
- let mut inner_err: Option<MagnusError> = None;
539
- hash.foreach(|k: Value, v: Value| {
540
- let key = match coerce_key(ruby, k) {
541
- Ok(s) => s,
542
- Err(e) => {
543
- inner_err = Some(e);
544
- return Ok(ForEach::Stop);
545
- }
546
- };
547
- match ruby_value_to_lora(ruby, v) {
548
- Ok(lv) => {
549
- out.insert(key, lv);
550
- Ok(ForEach::Continue)
551
- }
552
- Err(e) => {
553
- inner_err = Some(e);
554
- Ok(ForEach::Stop)
555
- }
556
- }
557
- })?;
558
- if let Some(e) = inner_err {
559
- return Err(e);
560
- }
561
- Ok(out)
562
- }
563
-
564
- fn coerce_key(ruby: &Ruby, v: Value) -> Result<String, MagnusError> {
565
- // Accept both String and Symbol keys — idiomatic Ruby. Reject anything
566
- // else loudly; silently stringifying would mask caller mistakes.
567
- if let Ok(s) = RString::try_convert(v) {
568
- return s.to_string();
569
- }
570
- if let Ok(s) = Symbol::try_convert(v) {
571
- return Ok(s.name()?.into_owned());
572
- }
573
- Err(invalid_params(ruby, "param keys must be String or Symbol"))
574
- }
575
-
576
- fn ruby_value_to_lora(ruby: &Ruby, v: Value) -> Result<LoraValue, MagnusError> {
577
- if v.is_nil() {
578
- return Ok(LoraValue::Null);
579
- }
580
- // Check true/false before Integer — Ruby's TrueClass / FalseClass are
581
- // not Integer subclasses, but bool detection is cleaner first.
582
- if v.is_kind_of(ruby.class_true_class()) {
583
- return Ok(LoraValue::Bool(true));
584
- }
585
- if v.is_kind_of(ruby.class_false_class()) {
586
- return Ok(LoraValue::Bool(false));
587
- }
588
- // Float MUST be checked before Integer — `Integer::try_convert`
589
- // succeeds on Float because Ruby's `Float#to_int` (truncating
590
- // coercion) makes `Float` implicitly convertible. Taking that path
591
- // would turn `1.5` into `1` silently; callers never want that.
592
- if let Ok(f) = Float::try_convert(v) {
593
- return Ok(LoraValue::Float(f.to_f64()));
594
- }
595
- if let Ok(i) = Integer::try_convert(v) {
596
- return match i.to_i64() {
597
- Ok(n) => Ok(LoraValue::Int(n)),
598
- Err(_) => Err(invalid_params(
599
- ruby,
600
- "integer parameter does not fit in i64",
601
- )),
602
- };
603
- }
604
- if let Ok(s) = RString::try_convert(v) {
605
- return Ok(LoraValue::String(s.to_string()?));
606
- }
607
- if let Ok(sym) = Symbol::try_convert(v) {
608
- // Symbols round-trip as strings — same approach as YAML/JSON
609
- // mappings. Engine has no dedicated symbol value.
610
- return Ok(LoraValue::String(sym.name()?.into_owned()));
611
- }
612
- if let Ok(arr) = RArray::try_convert(v) {
613
- let mut out = Vec::with_capacity(arr.len());
614
- for item in arr.into_iter() {
615
- out.push(ruby_value_to_lora(ruby, item)?);
616
- }
617
- return Ok(LoraValue::List(out));
618
- }
619
- if let Ok(hash) = RHash::try_convert(v) {
620
- return ruby_hash_to_cypher(ruby, hash);
621
- }
622
- let class_name = unsafe { v.classname() }.into_owned();
623
- Err(invalid_params(
624
- ruby,
625
- format!("unsupported parameter type: {class_name}"),
626
- ))
627
- }
628
-
629
- /// A Hash might be a tagged value (date / time / …/ point) or a plain
630
- /// map. Nodes / relationships / paths are opaque on the engine side and
631
- /// cannot be reconstructed as params — there's no `"kind" => "node"`
632
- /// tag handled here.
633
- fn ruby_hash_to_cypher(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
634
- if let Some(kind) = lookup_kind(ruby, hash)? {
635
- match kind.as_str() {
636
- "date" => {
637
- return parse_tagged(ruby, hash, "date", |iso| {
638
- LoraDate::parse(iso).map(LoraValue::Date)
639
- });
640
- }
641
- "time" => {
642
- return parse_tagged(ruby, hash, "time", |iso| {
643
- LoraTime::parse(iso).map(LoraValue::Time)
644
- });
645
- }
646
- "localtime" => {
647
- return parse_tagged(ruby, hash, "localtime", |iso| {
648
- LoraLocalTime::parse(iso).map(LoraValue::LocalTime)
649
- });
650
- }
651
- "datetime" => {
652
- return parse_tagged(ruby, hash, "datetime", |iso| {
653
- LoraDateTime::parse(iso).map(LoraValue::DateTime)
654
- });
655
- }
656
- "localdatetime" => {
657
- return parse_tagged(ruby, hash, "localdatetime", |iso| {
658
- LoraLocalDateTime::parse(iso).map(LoraValue::LocalDateTime)
659
- });
660
- }
661
- "duration" => {
662
- return parse_tagged(ruby, hash, "duration", |iso| {
663
- LoraDuration::parse(iso).map(LoraValue::Duration)
664
- });
665
- }
666
- "point" => return build_point(ruby, hash),
667
- "vector" => return build_vector(ruby, hash),
668
- _ => { /* fall through to plain-map handling */ }
669
- }
670
- }
671
-
672
- Ok(LoraValue::Map(hash_to_string_map(ruby, hash)?))
673
- }
674
-
675
- /// Look up `"kind"` (string) or `:kind` (symbol) under either key. Keeps
676
- /// constructor hashes usable with either Ruby idiom.
677
- fn lookup_kind(ruby: &Ruby, hash: RHash) -> Result<Option<String>, MagnusError> {
678
- if let Some(v) = hash.get(ruby.str_new("kind")) {
679
- return kind_as_string(v).map(Some);
680
- }
681
- if let Some(v) = hash.get(ruby.to_symbol("kind")) {
682
- return kind_as_string(v).map(Some);
683
- }
684
- Ok(None)
685
- }
686
-
687
- fn kind_as_string(v: Value) -> Result<String, MagnusError> {
688
- if let Ok(s) = RString::try_convert(v) {
689
- return s.to_string();
690
- }
691
- if let Ok(s) = Symbol::try_convert(v) {
692
- return Ok(s.name()?.into_owned());
693
- }
694
- // Anything else means "not a tagged constructor" — return empty so
695
- // the caller falls through to plain-map handling instead of raising.
696
- Ok(String::new())
697
- }
698
-
699
- fn parse_tagged(
700
- ruby: &Ruby,
701
- hash: RHash,
702
- tag: &str,
703
- parse: impl FnOnce(&str) -> Result<LoraValue, String>,
704
- ) -> Result<LoraValue, MagnusError> {
705
- let iso = read_string(ruby, hash, "iso")?
706
- .ok_or_else(|| invalid_params(ruby, format!("{tag} value requires iso: String")))?;
707
- parse(&iso).map_err(|e| invalid_params(ruby, format!("{tag}: {e}")))
708
- }
709
-
710
- fn build_point(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
711
- let srid = read_u32(ruby, hash, "srid")?.unwrap_or(7203);
712
- let x = read_f64(ruby, hash, "x")?.ok_or_else(|| invalid_params(ruby, "point.x required"))?;
713
- let y = read_f64(ruby, hash, "y")?.ok_or_else(|| invalid_params(ruby, "point.y required"))?;
714
- let z = read_f64(ruby, hash, "z")?;
715
- Ok(LoraValue::Point(LoraPoint { x, y, z, srid }))
716
- }
717
-
718
- fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
719
- let dimension = read_i64(ruby, hash, "dimension")?
720
- .ok_or_else(|| invalid_params(ruby, "vector.dimension required"))?;
721
- let coordinate_type_name = read_string(ruby, hash, "coordinateType")?
722
- .ok_or_else(|| invalid_params(ruby, "vector.coordinateType required"))?;
723
- let coordinate_type = VectorCoordinateType::parse(&coordinate_type_name).ok_or_else(|| {
724
- invalid_params(
725
- ruby,
726
- format!("unknown vector coordinate type '{coordinate_type_name}'"),
727
- )
728
- })?;
729
- let values_value = hash_get_either(ruby, hash, "values")
730
- .ok_or_else(|| invalid_params(ruby, "vector.values required"))?;
731
- let arr = RArray::try_convert(values_value)
732
- .map_err(|_| invalid_params(ruby, "vector.values must be an Array"))?;
733
-
734
- let mut raw = Vec::with_capacity(arr.len());
735
- for item in arr.into_iter() {
736
- if item.is_kind_of(ruby.class_true_class()) || item.is_kind_of(ruby.class_false_class()) {
737
- return Err(invalid_params(
738
- ruby,
739
- "vector.values entries must be numeric",
740
- ));
741
- }
742
- if let Ok(f) = Float::try_convert(item) {
743
- let v = f.to_f64();
744
- if !v.is_finite() {
745
- return Err(invalid_params(
746
- ruby,
747
- "vector.values cannot be NaN or Infinity",
748
- ));
749
- }
750
- raw.push(RawCoordinate::Float(v));
751
- continue;
752
- }
753
- if let Ok(i) = Integer::try_convert(item) {
754
- raw.push(RawCoordinate::Int(i.to_i64()?));
755
- continue;
756
- }
757
- return Err(invalid_params(
758
- ruby,
759
- "vector.values entries must be numeric",
760
- ));
761
- }
762
-
763
- let v = LoraVector::try_new(raw, dimension, coordinate_type)
764
- .map_err(|e| invalid_params(ruby, e.to_string()))?;
765
- Ok(LoraValue::Vector(v))
766
- }
767
-
768
- fn read_i64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<i64>, MagnusError> {
769
- let Some(v) = hash_get_either(ruby, hash, key) else {
770
- return Ok(None);
771
- };
772
- Ok(Some(Integer::try_convert(v)?.to_i64().map_err(|_| {
773
- invalid_params(ruby, format!("{key} out of i64 range"))
774
- })?))
775
- }
776
-
777
- // ---- Hash accessors that accept either string or symbol keys ------------
778
-
779
- fn hash_get_either(ruby: &Ruby, hash: RHash, key: &str) -> Option<Value> {
780
- if let Some(v) = hash.get(ruby.str_new(key)) {
781
- return Some(v);
782
- }
783
- hash.get(ruby.to_symbol(key))
784
- }
785
-
786
- fn read_string(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<String>, MagnusError> {
787
- let Some(v) = hash_get_either(ruby, hash, key) else {
788
- return Ok(None);
789
- };
790
- let s = RString::try_convert(v)?.to_string()?;
791
- Ok(Some(s))
792
- }
793
-
794
- fn read_u32(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<u32>, MagnusError> {
795
- let Some(v) = hash_get_either(ruby, hash, key) else {
796
- return Ok(None);
797
- };
798
- let n = Integer::try_convert(v)?.to_i64()?;
799
- u32::try_from(n)
800
- .map(Some)
801
- .map_err(|_| invalid_params(ruby, "srid out of u32 range"))
802
- }
803
-
804
- fn read_f64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<f64>, MagnusError> {
805
- let Some(v) = hash_get_either(ruby, hash, key) else {
806
- return Ok(None);
529
+ /// `explain(query, params = nil)` — compile a query and return the plan
530
+ /// as a Ruby `Hash` without invoking the executor. Mutating queries
531
+ /// produce no side effects.
532
+ fn database_explain(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<RHash, MagnusError> {
533
+ let (query, params_value) = parse_query_params(ruby, args)?;
534
+ let params_map = match params_value {
535
+ Some(v) => Some(ruby_value_to_params(ruby, v)?),
536
+ None => None,
807
537
  };
808
- // Accept either Float or Integer — `cartesian(1, 2)` passing ints
809
- // shouldn't force the caller to call `.to_f` first.
810
- if let Ok(f) = Float::try_convert(v) {
811
- return Ok(Some(f.to_f64()));
812
- }
813
- if let Ok(i) = Integer::try_convert(v) {
814
- return Ok(Some(i.to_i64()? as f64));
815
- }
816
- Ok(None)
538
+ let db = database_inner(ruby, rb_self)?;
539
+ let plan = without_gvl(move || db.explain(&query, params_map))
540
+ .map_err(|e| query_error_from_anyhow(ruby, e))?;
541
+ query_plan_to_ruby(ruby, &plan)
817
542
  }
818
543
 
819
- // ============================================================================
820
- // GVL release
821
- // ============================================================================
822
-
823
- /// Run `f` with Ruby's Global VM Lock released.
544
+ /// `profile(query, params = nil)` — execute a query and return the plan
545
+ /// plus runtime metrics as a Ruby `Hash`.
824
546
  ///
825
- /// Semantics match `rb_thread_call_without_gvl` other Ruby threads can
826
- /// progress while `f` runs. The closure MUST NOT touch Ruby state (no
827
- /// `Value`s, no allocations into the Ruby heap), which we arrange by
828
- /// keeping all such work on the calling thread. Everything inside
829
- /// `database_execute`'s closure is pure Rust on pre-extracted data, so
830
- /// this is sound.
831
- fn without_gvl<F, R>(f: F) -> R
832
- where
833
- F: FnOnce() -> R,
834
- F: Send,
835
- R: Send,
836
- {
837
- struct Data<F, R> {
838
- func: Option<F>,
839
- result: MaybeUninit<R>,
840
- }
841
-
842
- unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
843
- where
844
- F: FnOnce() -> R,
845
- {
846
- let data = &mut *(data as *mut Data<F, R>);
847
- let f = data
848
- .func
849
- .take()
850
- .expect("without_gvl: closure already taken");
851
- data.result.write(f());
852
- std::ptr::null_mut()
853
- }
854
-
855
- let mut data = Data::<F, R> {
856
- func: Some(f),
857
- result: MaybeUninit::uninit(),
547
+ /// **PROFILE executes the query for real.** Mutating queries are
548
+ /// persisted exactly as in `execute`. Use `explain` to inspect a
549
+ /// mutating plan without running it.
550
+ fn database_profile(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<RHash, MagnusError> {
551
+ let (query, params_value) = parse_query_params(ruby, args)?;
552
+ let params_map = match params_value {
553
+ Some(v) => Some(ruby_value_to_params(ruby, v)?),
554
+ None => None,
858
555
  };
556
+ let db = database_inner(ruby, rb_self)?;
557
+ let prof = without_gvl(move || db.profile(&query, params_map))
558
+ .map_err(|e| query_error_from_anyhow(ruby, e))?;
559
+ query_profile_to_ruby(ruby, &prof)
560
+ }
859
561
 
860
- unsafe {
861
- rb_sys::rb_thread_call_without_gvl(
862
- Some(trampoline::<F, R>),
863
- &mut data as *mut _ as *mut c_void,
864
- // No unblock function — the engine doesn't implement
865
- // cooperative cancellation, and a forced longjmp out of a
866
- // mutex-holding section would be worse than waiting.
867
- None,
868
- std::ptr::null_mut(),
869
- );
870
- data.result.assume_init()
562
+ fn parse_query_params(ruby: &Ruby, args: &[Value]) -> Result<(String, Option<Value>), MagnusError> {
563
+ match args.len() {
564
+ 1 => Ok((RString::try_convert(args[0])?.to_string()?, None)),
565
+ 2 => {
566
+ let q = RString::try_convert(args[0])?.to_string()?;
567
+ let p = if args[1].is_nil() {
568
+ None
569
+ } else {
570
+ Some(args[1])
571
+ };
572
+ Ok((q, p))
573
+ }
574
+ n => Err(MagnusError::new(
575
+ ruby.exception_arg_error(),
576
+ format!("wrong number of arguments (given {n}, expected 1..2)"),
577
+ )),
871
578
  }
872
579
  }