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.
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, SnapshotConfig, SnapshotOptions, WalConfig,
29
- };
30
- use lora_store::{
31
- LoraBinary, LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraPoint,
32
- LoraTime, 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
  // ============================================================================
@@ -65,6 +69,8 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
65
69
  database.define_singleton_method("new", function!(database_new, -1))?;
66
70
  database.define_singleton_method("open_wal", function!(database_open_wal, -1))?;
67
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))?;
68
74
  database.define_method("clear", method!(database_clear, 0))?;
69
75
  database.define_method("close", method!(database_close, 0))?;
70
76
  database.define_method("node_count", method!(database_node_count, 0))?;
@@ -85,34 +91,6 @@ fn init(ruby: &Ruby) -> Result<(), MagnusError> {
85
91
  Ok(())
86
92
  }
87
93
 
88
- // ============================================================================
89
- // Error lookups
90
- // ============================================================================
91
-
92
- fn lora_module(ruby: &Ruby) -> RModule {
93
- ruby.class_object()
94
- .const_get::<_, RModule>("LoraRuby")
95
- .expect("LoraRuby module is defined by `init` before any method runs")
96
- }
97
-
98
- fn lora_error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
99
- // `const_get::<_, ExceptionClass>` converts the stored RClass into
100
- // an ExceptionClass — this is the sound path, because our subclasses
101
- // of StandardError retain the exception-class trait on the Ruby
102
- // side even though `define_class` typed them as RClass.
103
- lora_module(ruby)
104
- .const_get::<_, ExceptionClass>(name)
105
- .unwrap_or_else(|_| ruby.exception_standard_error())
106
- }
107
-
108
- fn query_error(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
109
- MagnusError::new(lora_error_class(ruby, "QueryError"), msg.into())
110
- }
111
-
112
- fn invalid_params(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
113
- MagnusError::new(lora_error_class(ruby, "InvalidParamsError"), msg.into())
114
- }
115
-
116
94
  // ============================================================================
117
95
  // Database
118
96
  // ============================================================================
@@ -252,7 +230,7 @@ fn database_save_snapshot(
252
230
  let (path, options) = snapshot_file_args(ruby, args)?;
253
231
  let db = database_inner(ruby, rb_self)?;
254
232
  let meta = without_gvl(move || db.save_snapshot_to_with_options(&path, &options))
255
- .map_err(|e| query_error(ruby, format!("{e}")))?;
233
+ .map_err(|e| query_error_from_anyhow(ruby, e))?;
256
234
  snapshot_meta_to_rhash(ruby, meta)
257
235
  }
258
236
 
@@ -265,14 +243,14 @@ fn database_load_snapshot(
265
243
  let db = database_inner(ruby, rb_self)?;
266
244
  let meta =
267
245
  without_gvl(move || db.load_snapshot_from_with_credentials(&path, credentials.as_ref()))
268
- .map_err(|e| query_error(ruby, format!("{e}")))?;
246
+ .map_err(|e| query_error_from_anyhow(ruby, e))?;
269
247
  snapshot_meta_to_rhash(ruby, meta)
270
248
  }
271
249
 
272
250
  fn snapshot_file_args(
273
251
  ruby: &Ruby,
274
252
  args: &[Value],
275
- ) -> Result<(String, lora_database::SnapshotOptions), MagnusError> {
253
+ ) -> Result<(String, SnapshotOptions), MagnusError> {
276
254
  match args.len() {
277
255
  1 | 2 => {
278
256
  let path = RString::try_convert(args[0])?.to_string()?;
@@ -526,7 +504,7 @@ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
526
504
  let row_arrays = match exec_result {
527
505
  Ok(QueryResult::RowArrays(r)) => r,
528
506
  Ok(_) => return Err(query_error(ruby, "expected RowArrays result")),
529
- Err(e) => return Err(query_error(ruby, format!("{e}"))),
507
+ Err(e) => return Err(query_error_from_anyhow(ruby, e)),
530
508
  };
531
509
 
532
510
  let out = ruby.hash_new();
@@ -548,627 +526,54 @@ fn database_execute(ruby: &Ruby, rb_self: &Database, args: &[Value]) -> Result<R
548
526
  Ok(out)
549
527
  }
550
528
 
551
- // ============================================================================
552
- // LoraValue Ruby
553
- // ============================================================================
554
-
555
- fn lora_value_to_ruby(ruby: &Ruby, value: &LoraValue) -> Result<Value, MagnusError> {
556
- match value {
557
- LoraValue::Null => Ok(ruby.qnil().as_value()),
558
- LoraValue::Bool(b) => Ok(if *b {
559
- ruby.qtrue().as_value()
560
- } else {
561
- ruby.qfalse().as_value()
562
- }),
563
- LoraValue::Int(i) => Ok(ruby.integer_from_i64(*i).as_value()),
564
- LoraValue::Float(f) => Ok(ruby.float_from_f64(*f).as_value()),
565
- LoraValue::String(s) => Ok(ruby.str_new(s).as_value()),
566
- LoraValue::List(items) => {
567
- let arr = ruby.ary_new();
568
- for item in items {
569
- arr.push(lora_value_to_ruby(ruby, item)?)?;
570
- }
571
- Ok(arr.as_value())
572
- }
573
- LoraValue::Map(m) => {
574
- let h = ruby.hash_new();
575
- for (k, v) in m {
576
- h.aset(ruby.str_new(k), lora_value_to_ruby(ruby, v)?)?;
577
- }
578
- Ok(h.as_value())
579
- }
580
- LoraValue::Node(id) => {
581
- let h = ruby.hash_new();
582
- h.aset(ruby.str_new("kind"), ruby.str_new("node"))?;
583
- h.aset(ruby.str_new("id"), ruby.integer_from_i64(*id as i64))?;
584
- h.aset(ruby.str_new("labels"), ruby.ary_new())?;
585
- h.aset(ruby.str_new("properties"), ruby.hash_new())?;
586
- Ok(h.as_value())
587
- }
588
- LoraValue::Relationship(id) => {
589
- let h = ruby.hash_new();
590
- h.aset(ruby.str_new("kind"), ruby.str_new("relationship"))?;
591
- h.aset(ruby.str_new("id"), ruby.integer_from_i64(*id as i64))?;
592
- Ok(h.as_value())
593
- }
594
- LoraValue::Path(p) => {
595
- let h = ruby.hash_new();
596
- h.aset(ruby.str_new("kind"), ruby.str_new("path"))?;
597
- let nodes = ruby.ary_new();
598
- for n in &p.nodes {
599
- nodes.push(ruby.integer_from_i64(*n as i64))?;
600
- }
601
- let rels = ruby.ary_new();
602
- for r in &p.rels {
603
- rels.push(ruby.integer_from_i64(*r as i64))?;
604
- }
605
- h.aset(ruby.str_new("nodes"), nodes)?;
606
- h.aset(ruby.str_new("rels"), rels)?;
607
- Ok(h.as_value())
608
- }
609
- LoraValue::Date(v) => tagged_iso(ruby, "date", v.to_string()),
610
- LoraValue::Time(v) => tagged_iso(ruby, "time", v.to_string()),
611
- LoraValue::LocalTime(v) => tagged_iso(ruby, "localtime", v.to_string()),
612
- LoraValue::DateTime(v) => tagged_iso(ruby, "datetime", v.to_string()),
613
- LoraValue::LocalDateTime(v) => tagged_iso(ruby, "localdatetime", v.to_string()),
614
- LoraValue::Duration(v) => tagged_iso(ruby, "duration", v.to_string()),
615
- LoraValue::Point(p) => point_to_ruby(ruby, p),
616
- LoraValue::Vector(v) => vector_to_ruby(ruby, v),
617
- LoraValue::Binary(v) => binary_to_ruby(ruby, v),
618
- }
619
- }
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
-
636
- fn vector_to_ruby(ruby: &Ruby, v: &LoraVector) -> Result<Value, MagnusError> {
637
- let h = ruby.hash_new();
638
- h.aset(ruby.str_new("kind"), ruby.str_new("vector"))?;
639
- h.aset(
640
- ruby.str_new("dimension"),
641
- ruby.integer_from_i64(v.dimension as i64),
642
- )?;
643
- h.aset(
644
- ruby.str_new("coordinateType"),
645
- ruby.str_new(v.coordinate_type().as_str()),
646
- )?;
647
-
648
- let values = ruby.ary_new();
649
- match &v.values {
650
- VectorValues::Float64(vs) => {
651
- for x in vs {
652
- values.push(ruby.float_from_f64(*x))?;
653
- }
654
- }
655
- VectorValues::Float32(vs) => {
656
- for x in vs {
657
- values.push(ruby.float_from_f64(*x as f64))?;
658
- }
659
- }
660
- VectorValues::Integer64(vs) => {
661
- for x in vs {
662
- values.push(ruby.integer_from_i64(*x))?;
663
- }
664
- }
665
- VectorValues::Integer32(vs) => {
666
- for x in vs {
667
- values.push(ruby.integer_from_i64(*x as i64))?;
668
- }
669
- }
670
- VectorValues::Integer16(vs) => {
671
- for x in vs {
672
- values.push(ruby.integer_from_i64(*x as i64))?;
673
- }
674
- }
675
- VectorValues::Integer8(vs) => {
676
- for x in vs {
677
- values.push(ruby.integer_from_i64(*x as i64))?;
678
- }
679
- }
680
- }
681
- h.aset(ruby.str_new("values"), values)?;
682
- Ok(h.as_value())
683
- }
684
-
685
- fn tagged_iso(ruby: &Ruby, kind: &str, iso: String) -> Result<Value, MagnusError> {
686
- let h = ruby.hash_new();
687
- h.aset(ruby.str_new("kind"), ruby.str_new(kind))?;
688
- h.aset(ruby.str_new("iso"), ruby.str_new(&iso))?;
689
- Ok(h.as_value())
690
- }
691
-
692
- /// Render a `LoraPoint` into the canonical external point shape — kept
693
- /// 1:1 aligned with the `LoraPoint` union emitted by `lora-node` /
694
- /// `lora-wasm` / `lora-python`.
695
- fn point_to_ruby(ruby: &Ruby, p: &LoraPoint) -> Result<Value, MagnusError> {
696
- let h = ruby.hash_new();
697
- h.aset(ruby.str_new("kind"), ruby.str_new("point"))?;
698
- h.aset(ruby.str_new("srid"), ruby.integer_from_i64(p.srid as i64))?;
699
- h.aset(ruby.str_new("crs"), ruby.str_new(p.crs_name()))?;
700
- h.aset(ruby.str_new("x"), ruby.float_from_f64(p.x))?;
701
- h.aset(ruby.str_new("y"), ruby.float_from_f64(p.y))?;
702
- if let Some(z) = p.z {
703
- h.aset(ruby.str_new("z"), ruby.float_from_f64(z))?;
704
- }
705
- if p.is_geographic() {
706
- h.aset(
707
- ruby.str_new("longitude"),
708
- ruby.float_from_f64(p.longitude()),
709
- )?;
710
- h.aset(ruby.str_new("latitude"), ruby.float_from_f64(p.latitude()))?;
711
- if let Some(height) = p.height() {
712
- h.aset(ruby.str_new("height"), ruby.float_from_f64(height))?;
713
- }
714
- }
715
- Ok(h.as_value())
716
- }
717
-
718
- // ============================================================================
719
- // Ruby → LoraValue (params)
720
- // ============================================================================
721
-
722
- fn ruby_value_to_params(
723
- ruby: &Ruby,
724
- value: Value,
725
- ) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
726
- let hash = RHash::try_convert(value)
727
- .map_err(|_| invalid_params(ruby, "params must be a Hash keyed by parameter name"))?;
728
- hash_to_string_map(ruby, hash)
729
- }
730
-
731
- fn hash_to_string_map(
732
- ruby: &Ruby,
733
- hash: RHash,
734
- ) -> Result<BTreeMap<String, LoraValue>, MagnusError> {
735
- let mut out = BTreeMap::new();
736
- let mut inner_err: Option<MagnusError> = None;
737
- hash.foreach(|k: Value, v: Value| {
738
- let key = match coerce_key(ruby, k) {
739
- Ok(s) => s,
740
- Err(e) => {
741
- inner_err = Some(e);
742
- return Ok(ForEach::Stop);
743
- }
744
- };
745
- match ruby_value_to_lora(ruby, v) {
746
- Ok(lv) => {
747
- out.insert(key, lv);
748
- Ok(ForEach::Continue)
749
- }
750
- Err(e) => {
751
- inner_err = Some(e);
752
- Ok(ForEach::Stop)
753
- }
754
- }
755
- })?;
756
- if let Some(e) = inner_err {
757
- return Err(e);
758
- }
759
- Ok(out)
760
- }
761
-
762
- fn coerce_key(ruby: &Ruby, v: Value) -> Result<String, MagnusError> {
763
- // Accept both String and Symbol keys — idiomatic Ruby. Reject anything
764
- // else loudly; silently stringifying would mask caller mistakes.
765
- if let Ok(s) = RString::try_convert(v) {
766
- return s.to_string();
767
- }
768
- if let Ok(s) = Symbol::try_convert(v) {
769
- return Ok(s.name()?.into_owned());
770
- }
771
- Err(invalid_params(ruby, "param keys must be String or Symbol"))
772
- }
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
-
854
- fn ruby_value_to_lora(ruby: &Ruby, v: Value) -> Result<LoraValue, MagnusError> {
855
- if v.is_nil() {
856
- return Ok(LoraValue::Null);
857
- }
858
- // Check true/false before Integer — Ruby's TrueClass / FalseClass are
859
- // not Integer subclasses, but bool detection is cleaner first.
860
- if v.is_kind_of(ruby.class_true_class()) {
861
- return Ok(LoraValue::Bool(true));
862
- }
863
- if v.is_kind_of(ruby.class_false_class()) {
864
- return Ok(LoraValue::Bool(false));
865
- }
866
- // Float MUST be checked before Integer — `Integer::try_convert`
867
- // succeeds on Float because Ruby's `Float#to_int` (truncating
868
- // coercion) makes `Float` implicitly convertible. Taking that path
869
- // would turn `1.5` into `1` silently; callers never want that.
870
- if let Ok(f) = Float::try_convert(v) {
871
- return Ok(LoraValue::Float(f.to_f64()));
872
- }
873
- if let Ok(i) = Integer::try_convert(v) {
874
- return match i.to_i64() {
875
- Ok(n) => Ok(LoraValue::Int(n)),
876
- Err(_) => Err(invalid_params(
877
- ruby,
878
- "integer parameter does not fit in i64",
879
- )),
880
- };
881
- }
882
- if let Ok(s) = RString::try_convert(v) {
883
- return Ok(LoraValue::String(s.to_string()?));
884
- }
885
- if let Ok(sym) = Symbol::try_convert(v) {
886
- // Symbols round-trip as strings — same approach as YAML/JSON
887
- // mappings. Engine has no dedicated symbol value.
888
- return Ok(LoraValue::String(sym.name()?.into_owned()));
889
- }
890
- if let Ok(arr) = RArray::try_convert(v) {
891
- let mut out = Vec::with_capacity(arr.len());
892
- for item in arr.into_iter() {
893
- out.push(ruby_value_to_lora(ruby, item)?);
894
- }
895
- return Ok(LoraValue::List(out));
896
- }
897
- if let Ok(hash) = RHash::try_convert(v) {
898
- return ruby_hash_to_cypher(ruby, hash);
899
- }
900
- let class_name = unsafe { v.classname() }.into_owned();
901
- Err(invalid_params(
902
- ruby,
903
- format!("unsupported parameter type: {class_name}"),
904
- ))
905
- }
906
-
907
- /// A Hash might be a tagged value (date / time / …/ point) or a plain
908
- /// map. Nodes / relationships / paths are opaque on the engine side and
909
- /// cannot be reconstructed as params — there's no `"kind" => "node"`
910
- /// tag handled here.
911
- fn ruby_hash_to_cypher(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
912
- if let Some(kind) = lookup_kind(ruby, hash)? {
913
- match kind.as_str() {
914
- "date" => {
915
- return parse_tagged(ruby, hash, "date", |iso| {
916
- LoraDate::parse(iso).map(LoraValue::Date)
917
- });
918
- }
919
- "time" => {
920
- return parse_tagged(ruby, hash, "time", |iso| {
921
- LoraTime::parse(iso).map(LoraValue::Time)
922
- });
923
- }
924
- "localtime" => {
925
- return parse_tagged(ruby, hash, "localtime", |iso| {
926
- LoraLocalTime::parse(iso).map(LoraValue::LocalTime)
927
- });
928
- }
929
- "datetime" => {
930
- return parse_tagged(ruby, hash, "datetime", |iso| {
931
- LoraDateTime::parse(iso).map(LoraValue::DateTime)
932
- });
933
- }
934
- "localdatetime" => {
935
- return parse_tagged(ruby, hash, "localdatetime", |iso| {
936
- LoraLocalDateTime::parse(iso).map(LoraValue::LocalDateTime)
937
- });
938
- }
939
- "duration" => {
940
- return parse_tagged(ruby, hash, "duration", |iso| {
941
- LoraDuration::parse(iso).map(LoraValue::Duration)
942
- });
943
- }
944
- "point" => return build_point(ruby, hash),
945
- "vector" => return build_vector(ruby, hash),
946
- "binary" | "blob" => return build_binary(ruby, hash),
947
- _ => { /* fall through to plain-map handling */ }
948
- }
949
- }
950
-
951
- Ok(LoraValue::Map(hash_to_string_map(ruby, hash)?))
952
- }
953
-
954
- /// Look up `"kind"` (string) or `:kind` (symbol) under either key. Keeps
955
- /// constructor hashes usable with either Ruby idiom.
956
- fn lookup_kind(ruby: &Ruby, hash: RHash) -> Result<Option<String>, MagnusError> {
957
- if let Some(v) = hash.get(ruby.str_new("kind")) {
958
- return kind_as_string(v).map(Some);
959
- }
960
- if let Some(v) = hash.get(ruby.to_symbol("kind")) {
961
- return kind_as_string(v).map(Some);
962
- }
963
- Ok(None)
964
- }
965
-
966
- fn kind_as_string(v: Value) -> Result<String, MagnusError> {
967
- if let Ok(s) = RString::try_convert(v) {
968
- return s.to_string();
969
- }
970
- if let Ok(s) = Symbol::try_convert(v) {
971
- return Ok(s.name()?.into_owned());
972
- }
973
- // Anything else means "not a tagged constructor" — return empty so
974
- // the caller falls through to plain-map handling instead of raising.
975
- Ok(String::new())
976
- }
977
-
978
- fn parse_tagged(
979
- ruby: &Ruby,
980
- hash: RHash,
981
- tag: &str,
982
- parse: impl FnOnce(&str) -> Result<LoraValue, String>,
983
- ) -> Result<LoraValue, MagnusError> {
984
- let iso = read_string(ruby, hash, "iso")?
985
- .ok_or_else(|| invalid_params(ruby, format!("{tag} value requires iso: String")))?;
986
- parse(&iso).map_err(|e| invalid_params(ruby, format!("{tag}: {e}")))
987
- }
988
-
989
- fn build_point(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
990
- let srid = read_u32(ruby, hash, "srid")?.unwrap_or(7203);
991
- let x = read_f64(ruby, hash, "x")?.ok_or_else(|| invalid_params(ruby, "point.x required"))?;
992
- let y = read_f64(ruby, hash, "y")?.ok_or_else(|| invalid_params(ruby, "point.y required"))?;
993
- let z = read_f64(ruby, hash, "z")?;
994
- Ok(LoraValue::Point(LoraPoint { x, y, z, srid }))
995
- }
996
-
997
- fn build_vector(ruby: &Ruby, hash: RHash) -> Result<LoraValue, MagnusError> {
998
- let dimension = read_i64(ruby, hash, "dimension")?
999
- .ok_or_else(|| invalid_params(ruby, "vector.dimension required"))?;
1000
- let coordinate_type_name = read_string(ruby, hash, "coordinateType")?
1001
- .ok_or_else(|| invalid_params(ruby, "vector.coordinateType required"))?;
1002
- let coordinate_type = VectorCoordinateType::parse(&coordinate_type_name).ok_or_else(|| {
1003
- invalid_params(
1004
- ruby,
1005
- format!("unknown vector coordinate type '{coordinate_type_name}'"),
1006
- )
1007
- })?;
1008
- let values_value = hash_get_either(ruby, hash, "values")
1009
- .ok_or_else(|| invalid_params(ruby, "vector.values required"))?;
1010
- let arr = RArray::try_convert(values_value)
1011
- .map_err(|_| invalid_params(ruby, "vector.values must be an Array"))?;
1012
-
1013
- let mut raw = Vec::with_capacity(arr.len());
1014
- for item in arr.into_iter() {
1015
- if item.is_kind_of(ruby.class_true_class()) || item.is_kind_of(ruby.class_false_class()) {
1016
- return Err(invalid_params(
1017
- ruby,
1018
- "vector.values entries must be numeric",
1019
- ));
1020
- }
1021
- if let Ok(f) = Float::try_convert(item) {
1022
- let v = f.to_f64();
1023
- if !v.is_finite() {
1024
- return Err(invalid_params(
1025
- ruby,
1026
- "vector.values cannot be NaN or Infinity",
1027
- ));
1028
- }
1029
- raw.push(RawCoordinate::Float(v));
1030
- continue;
1031
- }
1032
- if let Ok(i) = Integer::try_convert(item) {
1033
- raw.push(RawCoordinate::Int(i.to_i64()?));
1034
- continue;
1035
- }
1036
- return Err(invalid_params(
1037
- ruby,
1038
- "vector.values entries must be numeric",
1039
- ));
1040
- }
1041
-
1042
- let v = LoraVector::try_new(raw, dimension, coordinate_type)
1043
- .map_err(|e| invalid_params(ruby, e.to_string()))?;
1044
- Ok(LoraValue::Vector(v))
1045
- }
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
-
1061
- fn read_i64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<i64>, MagnusError> {
1062
- let Some(v) = hash_get_either(ruby, hash, key) else {
1063
- return Ok(None);
1064
- };
1065
- Ok(Some(Integer::try_convert(v)?.to_i64().map_err(|_| {
1066
- invalid_params(ruby, format!("{key} out of i64 range"))
1067
- })?))
1068
- }
1069
-
1070
- // ---- Hash accessors that accept either string or symbol keys ------------
1071
-
1072
- fn hash_get_either(ruby: &Ruby, hash: RHash, key: &str) -> Option<Value> {
1073
- if let Some(v) = hash.get(ruby.str_new(key)) {
1074
- return Some(v);
1075
- }
1076
- hash.get(ruby.to_symbol(key))
1077
- }
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
-
1088
- fn read_string(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<String>, MagnusError> {
1089
- let Some(v) = hash_get_either(ruby, hash, key) else {
1090
- return Ok(None);
1091
- };
1092
- let s = RString::try_convert(v)?.to_string()?;
1093
- Ok(Some(s))
1094
- }
1095
-
1096
- fn read_u32(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<u32>, MagnusError> {
1097
- let Some(v) = hash_get_either(ruby, hash, key) else {
1098
- return Ok(None);
1099
- };
1100
- let n = Integer::try_convert(v)?.to_i64()?;
1101
- u32::try_from(n)
1102
- .map(Some)
1103
- .map_err(|_| invalid_params(ruby, "srid out of u32 range"))
1104
- }
1105
-
1106
- fn read_f64(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<f64>, MagnusError> {
1107
- let Some(v) = hash_get_either(ruby, hash, key) else {
1108
- 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,
1109
537
  };
1110
- // Accept either Float or Integer — `cartesian(1, 2)` passing ints
1111
- // shouldn't force the caller to call `.to_f` first.
1112
- if let Ok(f) = Float::try_convert(v) {
1113
- return Ok(Some(f.to_f64()));
1114
- }
1115
- if let Ok(i) = Integer::try_convert(v) {
1116
- return Ok(Some(i.to_i64()? as f64));
1117
- }
1118
- 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)
1119
542
  }
1120
543
 
1121
- // ============================================================================
1122
- // GVL release
1123
- // ============================================================================
1124
-
1125
- /// 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`.
1126
546
  ///
1127
- /// Semantics match `rb_thread_call_without_gvl` other Ruby threads can
1128
- /// progress while `f` runs. The closure MUST NOT touch Ruby state (no
1129
- /// `Value`s, no allocations into the Ruby heap), which we arrange by
1130
- /// keeping all such work on the calling thread. Everything inside
1131
- /// `database_execute`'s closure is pure Rust on pre-extracted data, so
1132
- /// this is sound.
1133
- fn without_gvl<F, R>(f: F) -> R
1134
- where
1135
- F: FnOnce() -> R,
1136
- F: Send,
1137
- R: Send,
1138
- {
1139
- struct Data<F, R> {
1140
- func: Option<F>,
1141
- result: MaybeUninit<R>,
1142
- }
1143
-
1144
- unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
1145
- where
1146
- F: FnOnce() -> R,
1147
- {
1148
- let data = &mut *(data as *mut Data<F, R>);
1149
- let f = data
1150
- .func
1151
- .take()
1152
- .expect("without_gvl: closure already taken");
1153
- data.result.write(f());
1154
- std::ptr::null_mut()
1155
- }
1156
-
1157
- let mut data = Data::<F, R> {
1158
- func: Some(f),
1159
- 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,
1160
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
+ }
1161
561
 
1162
- unsafe {
1163
- rb_sys::rb_thread_call_without_gvl(
1164
- Some(trampoline::<F, R>),
1165
- &mut data as *mut _ as *mut c_void,
1166
- // No unblock function — the engine doesn't implement
1167
- // cooperative cancellation, and a forced longjmp out of a
1168
- // mutex-holding section would be worse than waiting.
1169
- None,
1170
- std::ptr::null_mut(),
1171
- );
1172
- 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
+ )),
1173
578
  }
1174
579
  }