stoolap 0.4.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.
@@ -0,0 +1,296 @@
1
+ // Copyright 2025 Stoolap Contributors
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ use std::cell::RefCell;
16
+
17
+ use chrono::{TimeZone, Utc};
18
+ use magnus::{
19
+ prelude::*,
20
+ r_hash::ForEach,
21
+ value::{Lazy, ReprValue},
22
+ Error, Float, Integer, RArray, RClass, RHash, RModule, RString, Ruby, Symbol, TryConvert,
23
+ Value,
24
+ };
25
+
26
+ use stoolap::api::{NamedParams, ParamVec};
27
+ use stoolap::core::Value as SValue;
28
+
29
+ use crate::error::{raise, type_error};
30
+
31
+ /// Cached `Time` class. Lazy-initialised on first use; subsequent calls
32
+ /// are a direct pointer load, avoiding `const_get("Time")` on every
33
+ /// timestamp parameter and every timestamp result value.
34
+ static TIME_CLASS: Lazy<RClass> = Lazy::new(|ruby| {
35
+ ruby.class_object()
36
+ .const_get("Time")
37
+ .expect("Ruby core class Time must exist")
38
+ });
39
+
40
+ /// Cached `:nsec` symbol used for `Time.at(secs, nsecs, :nsec)`.
41
+ static NSEC_SYM: Lazy<Symbol> = Lazy::new(|ruby| ruby.to_symbol("nsec"));
42
+
43
+ /// Cached `JSON` module used to serialise Hash/Array params.
44
+ /// `lib/stoolap.rb` does `require "json"` so the constant is
45
+ /// guaranteed to exist by the time any binding method runs. The
46
+ /// `.expect` is a last-resort guard, not a normal error path.
47
+ static JSON_MODULE: Lazy<RModule> = Lazy::new(|ruby| {
48
+ ruby.class_object()
49
+ .const_get("JSON")
50
+ .expect("JSON constant must exist (require \"json\" should have loaded it)")
51
+ });
52
+
53
+ /// A vector of f32 values for similarity search.
54
+ ///
55
+ /// Wraps a list of floats so that Stoolap stores them as a native VECTOR
56
+ /// rather than a JSON array.
57
+ ///
58
+ /// @example
59
+ /// v = Stoolap::Vector.new([0.1, 0.2, 0.3])
60
+ /// db.execute("INSERT INTO t (embedding) VALUES ($1)", [v])
61
+ #[magnus::wrap(class = "Stoolap::Vector", free_immediately, size)]
62
+ pub struct Vector {
63
+ pub data: RefCell<Vec<f32>>,
64
+ }
65
+
66
+ impl Vector {
67
+ pub fn new(data: RArray) -> Result<Self, Error> {
68
+ let mut floats: Vec<f32> = Vec::with_capacity(data.len());
69
+ for item in data.into_iter() {
70
+ let f: f64 = match TryConvert::try_convert(item) {
71
+ Ok(v) => v,
72
+ Err(_) => {
73
+ return Err(type_error("Vector elements must be numeric"));
74
+ }
75
+ };
76
+ floats.push(f as f32);
77
+ }
78
+ Ok(Self {
79
+ data: RefCell::new(floats),
80
+ })
81
+ }
82
+
83
+ pub fn to_a(&self) -> Vec<f64> {
84
+ self.data.borrow().iter().map(|f| *f as f64).collect()
85
+ }
86
+
87
+ pub fn length(&self) -> usize {
88
+ self.data.borrow().len()
89
+ }
90
+
91
+ pub fn inspect(&self) -> String {
92
+ let v = self.data.borrow();
93
+ let parts: Vec<String> = v.iter().map(|f| format!("{}", f)).collect();
94
+ format!("#<Stoolap::Vector [{}]>", parts.join(", "))
95
+ }
96
+
97
+ /// Internal: snapshot the contained data into a fresh Vec.
98
+ pub fn snapshot(&self) -> Vec<f32> {
99
+ self.data.borrow().clone()
100
+ }
101
+ }
102
+
103
+ /// Parsed bind parameters from Ruby.
104
+ pub enum BindParams {
105
+ Positional(ParamVec),
106
+ Named(NamedParams),
107
+ }
108
+
109
+ /// Convert a Ruby Value into a Stoolap Value.
110
+ pub fn ruby_to_value(val: Value) -> Result<SValue, Error> {
111
+ let ruby = magnus::Ruby::get().expect("must hold the Ruby VM lock");
112
+
113
+ if val.is_nil() {
114
+ return Ok(SValue::null_unknown());
115
+ }
116
+
117
+ // Boolean
118
+ if val.is_kind_of(ruby.class_true_class()) {
119
+ return Ok(SValue::Boolean(true));
120
+ }
121
+ if val.is_kind_of(ruby.class_false_class()) {
122
+ return Ok(SValue::Boolean(false));
123
+ }
124
+
125
+ // Integer
126
+ if let Some(i) = Integer::from_value(val) {
127
+ let n: i64 = i.to_i64()?;
128
+ return Ok(SValue::Integer(n));
129
+ }
130
+
131
+ // Float
132
+ if let Some(f) = Float::from_value(val) {
133
+ return Ok(SValue::Float(f.to_f64()));
134
+ }
135
+
136
+ // String
137
+ if let Some(s) = RString::from_value(val) {
138
+ let owned = unsafe { s.as_str()?.to_owned() };
139
+ return Ok(SValue::text(owned));
140
+ }
141
+
142
+ // Symbol -> string
143
+ if let Some(sym) = Symbol::from_value(val) {
144
+ return Ok(SValue::text(sym.name()?.into_owned()));
145
+ }
146
+
147
+ // Stoolap::Vector wrapper
148
+ if let Ok(v) = <&Vector>::try_convert(val) {
149
+ return Ok(SValue::vector(v.snapshot()));
150
+ }
151
+
152
+ // Time -> Timestamp (UTC)
153
+ let time_class = ruby.get_inner(&TIME_CLASS);
154
+ if val.is_kind_of(time_class) {
155
+ let secs: i64 = val.funcall("to_i", ())?;
156
+ let nsecs: i64 = val.funcall("nsec", ())?;
157
+ let dt = Utc
158
+ .timestamp_opt(secs, nsecs as u32)
159
+ .single()
160
+ .ok_or_else(|| raise("invalid Time value"))?;
161
+ return Ok(SValue::Timestamp(dt));
162
+ }
163
+
164
+ // Array / Hash -> JSON
165
+ if RArray::from_value(val).is_some() || RHash::from_value(val).is_some() {
166
+ let json_module = ruby.get_inner(&JSON_MODULE);
167
+ let dumped: RString = json_module.funcall("dump", (val,))?;
168
+ let s = unsafe { dumped.as_str()?.to_owned() };
169
+ return Ok(SValue::json(s));
170
+ }
171
+
172
+ Err(type_error(format!(
173
+ "Unsupported parameter type: {}",
174
+ val.class().inspect()
175
+ )))
176
+ }
177
+
178
+ /// Parse Ruby params (Array, Hash, or nil) into BindParams.
179
+ pub fn parse_params(params: Option<Value>) -> Result<BindParams, Error> {
180
+ let params = match params {
181
+ None => return Ok(BindParams::Positional(ParamVec::new())),
182
+ Some(v) if v.is_nil() => return Ok(BindParams::Positional(ParamVec::new())),
183
+ Some(v) => v,
184
+ };
185
+
186
+ // Hash -> named params
187
+ if let Some(hash) = RHash::from_value(params) {
188
+ let mut named = NamedParams::new();
189
+ let mut err: Option<Error> = None;
190
+ let _ = hash.foreach(|key: Value, val: Value| {
191
+ // Extract the key as `&str` without an intermediate owned String.
192
+ // Symbols return a `Cow<str>` from `name()`; RStrings expose their
193
+ // bytes via `unsafe as_str` (valid while the Hash holds the key).
194
+ // A single `to_string` at `named.insert` is the only allocation.
195
+ let rstring = RString::from_value(key);
196
+ let symbol = Symbol::from_value(key);
197
+
198
+ let raw: &str = if let Some(sym) = symbol {
199
+ match sym.name() {
200
+ Ok(std::borrow::Cow::Borrowed(s)) => s,
201
+ // `sym.name()` returns Borrowed for pinned symbols in 3.x
202
+ // so the Owned branch should be unreachable in practice.
203
+ Ok(std::borrow::Cow::Owned(_)) => {
204
+ err = Some(type_error("dynamic symbol key not supported"));
205
+ return Ok(ForEach::Stop);
206
+ }
207
+ Err(e) => {
208
+ err = Some(e);
209
+ return Ok(ForEach::Stop);
210
+ }
211
+ }
212
+ } else if let Some(ref s) = rstring {
213
+ match unsafe { s.as_str() } {
214
+ Ok(s) => s,
215
+ Err(e) => {
216
+ err = Some(e);
217
+ return Ok(ForEach::Stop);
218
+ }
219
+ }
220
+ } else {
221
+ err = Some(type_error("named parameter keys must be Symbol or String"));
222
+ return Ok(ForEach::Stop);
223
+ };
224
+
225
+ let stripped = raw.trim_start_matches(&[':', '@', '$'][..]);
226
+
227
+ match ruby_to_value(val) {
228
+ Ok(v) => {
229
+ named.insert(stripped.to_string(), v);
230
+ Ok(ForEach::Continue)
231
+ }
232
+ Err(e) => {
233
+ err = Some(e);
234
+ Ok(ForEach::Stop)
235
+ }
236
+ }
237
+ });
238
+ if let Some(e) = err {
239
+ return Err(e);
240
+ }
241
+ return Ok(BindParams::Named(named));
242
+ }
243
+
244
+ // Array -> positional
245
+ if let Some(arr) = RArray::from_value(params) {
246
+ let mut values = ParamVec::new();
247
+ for item in arr.into_iter() {
248
+ values.push(ruby_to_value(item)?);
249
+ }
250
+ return Ok(BindParams::Positional(values));
251
+ }
252
+
253
+ Err(type_error("Parameters must be an Array, Hash, or nil"))
254
+ }
255
+
256
+ /// Convert a Stoolap Value to a Ruby Value.
257
+ pub fn value_to_ruby(val: &SValue) -> Result<Value, Error> {
258
+ let ruby = Ruby::get().expect("must hold the Ruby VM lock");
259
+ match val {
260
+ SValue::Null(_) => Ok(ruby.qnil().as_value()),
261
+ SValue::Boolean(b) => Ok(if *b {
262
+ ruby.qtrue().as_value()
263
+ } else {
264
+ ruby.qfalse().as_value()
265
+ }),
266
+ SValue::Integer(i) => Ok(ruby.integer_from_i64(*i).as_value()),
267
+ SValue::Float(f) => Ok(ruby.float_from_f64(*f).as_value()),
268
+ SValue::Text(s) => Ok(ruby.str_new(s.as_str()).as_value()),
269
+ SValue::Timestamp(ts) => {
270
+ // `Time.at(secs, nsecs, :nsec).utc` — Ruby's `Time#utc`
271
+ // mutates the receiver in place and returns self, so the
272
+ // chain is a single Time allocation. Cached Time class and
273
+ // :nsec symbol avoid const_get + intern per-row.
274
+ let time_class = ruby.get_inner(&TIME_CLASS);
275
+ let nsec_sym = ruby.get_inner(&NSEC_SYM);
276
+ let secs = ts.timestamp();
277
+ let nsecs = ts.timestamp_subsec_nanos() as i64;
278
+ let t: Value = time_class.funcall("at", (secs, nsecs, nsec_sym))?;
279
+ let utc: Value = t.funcall("utc", ())?;
280
+ Ok(utc)
281
+ }
282
+ SValue::Extension(_) => {
283
+ if let Some(floats) = val.as_vector_f32() {
284
+ let arr = ruby.ary_new_capa(floats.len());
285
+ for f in floats.iter() {
286
+ arr.push(*f as f64)?;
287
+ }
288
+ Ok(arr.as_value())
289
+ } else if let Some(s) = val.as_json() {
290
+ Ok(ruby.str_new(s).as_value())
291
+ } else {
292
+ Ok(ruby.str_new(&format!("{}", val)).as_value())
293
+ }
294
+ }
295
+ }
296
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoolap
4
+ VERSION = "0.4.0"
5
+ end
data/lib/stoolap.rb ADDED
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stoolap/version"
4
+ require "json"
5
+
6
+ # Native extension. Tries pre-compiled binary first (per-Ruby-ABI),
7
+ # falls back to a single-arch build at lib/stoolap/stoolap.<ext>.
8
+ begin
9
+ RUBY_VERSION =~ /(\d+\.\d+)/
10
+ require_relative "stoolap/#{Regexp.last_match(1)}/stoolap"
11
+ rescue LoadError
12
+ require_relative "stoolap/stoolap"
13
+ end
14
+
15
+ # Stoolap is a high-performance embedded SQL database for Ruby.
16
+ #
17
+ # @example In-memory database
18
+ # db = Stoolap::Database.open(":memory:")
19
+ # db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
20
+ # db.execute("INSERT INTO users VALUES ($1, $2)", [1, "Alice"])
21
+ # rows = db.query("SELECT * FROM users")
22
+ # # => [{"id" => 1, "name" => "Alice"}]
23
+ #
24
+ # @example File-backed database
25
+ # db = Stoolap::Database.open("./mydata")
26
+ #
27
+ # @example Block form (auto-closes)
28
+ # Stoolap::Database.open(":memory:") do |db|
29
+ # db.exec("CREATE TABLE t (id INTEGER)")
30
+ # end
31
+ module Stoolap
32
+ class Database
33
+ # Open a database. If a block is given, the database is closed
34
+ # automatically when the block returns (even on exception).
35
+ #
36
+ # @param path [String] DSN or file path. Use ":memory:" for in-memory.
37
+ # @yieldparam db [Database]
38
+ # @return [Database, Object] the database, or the block's return value
39
+ def self.open(path = ":memory:")
40
+ db = _open(path)
41
+ return db unless block_given?
42
+
43
+ begin
44
+ yield db
45
+ ensure
46
+ db.close
47
+ end
48
+ end
49
+
50
+ # Begin a transaction. If a block is given, the transaction commits
51
+ # on clean exit and rolls back on exception.
52
+ #
53
+ # @yieldparam tx [Transaction]
54
+ # @return [Transaction, Object]
55
+ def transaction
56
+ tx = begin_transaction
57
+ return tx unless block_given?
58
+
59
+ committed = false
60
+ begin
61
+ result = yield tx
62
+ tx.commit
63
+ committed = true
64
+ result
65
+ ensure
66
+ # Roll back on ANY exception (including Interrupt, SystemExit,
67
+ # and direct Exception subclasses), not just StandardError.
68
+ tx.rollback unless committed
69
+ end
70
+ end
71
+ end
72
+
73
+ class Transaction
74
+ # Yield this transaction; commit on success, rollback on exception.
75
+ def with_rollback
76
+ committed = false
77
+ begin
78
+ yield self
79
+ commit
80
+ committed = true
81
+ ensure
82
+ rollback unless committed
83
+ end
84
+ end
85
+ end
86
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stoolap
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Stoolap Contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rb_sys
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.9.91
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.9.91
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake-compiler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.2'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '5.20'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '5.20'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.22'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.22'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ description: Native Ruby driver for the Stoolap embedded SQL database. Built with
98
+ Magnus + rb-sys for zero-FFI Rust performance.
99
+ email:
100
+ executables: []
101
+ extensions:
102
+ - ext/stoolap/extconf.rb
103
+ extra_rdoc_files: []
104
+ files:
105
+ - Cargo.lock
106
+ - Cargo.toml
107
+ - LICENSE
108
+ - README.md
109
+ - ext/stoolap/Cargo.toml
110
+ - ext/stoolap/extconf.rb
111
+ - ext/stoolap/src/database.rs
112
+ - ext/stoolap/src/error.rs
113
+ - ext/stoolap/src/lib.rs
114
+ - ext/stoolap/src/statement.rs
115
+ - ext/stoolap/src/transaction.rs
116
+ - ext/stoolap/src/value.rs
117
+ - lib/stoolap.rb
118
+ - lib/stoolap/version.rb
119
+ homepage: https://stoolap.io
120
+ licenses:
121
+ - Apache-2.0
122
+ metadata:
123
+ source_code_uri: https://github.com/stoolap/stoolap-ruby
124
+ documentation_uri: https://stoolap.io/docs/drivers/ruby/
125
+ bug_tracker_uri: https://github.com/stoolap/stoolap-ruby/issues
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: 3.1.0
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: 3.3.11
140
+ requirements: []
141
+ rubygems_version: 3.5.22
142
+ signing_key:
143
+ specification_version: 4
144
+ summary: High-performance embedded SQL database for Ruby
145
+ test_files: []