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,193 @@
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::sync::{Arc, OnceLock};
16
+
17
+ use magnus::{
18
+ gc, prelude::*, scan_args::scan_args, value::Opaque, DataTypeFunctions, Error, RArray, RHash,
19
+ Ruby, TypedData, Value,
20
+ };
21
+
22
+ use stoolap::api::{Database as ApiDatabase, Rows};
23
+ use stoolap::CachedPlanRef;
24
+
25
+ use crate::database::{
26
+ first_row_to_hash_with_keys, rows_to_hashes_with_keys, rows_to_raw_with_keys,
27
+ };
28
+ use crate::error::{raise, to_magnus};
29
+ use crate::value::{parse_params, BindParams};
30
+
31
+ /// A prepared SQL statement.
32
+ ///
33
+ /// Parses SQL once and reuses the cached execution plan on every call.
34
+ ///
35
+ /// Also caches a frozen Ruby `RArray` of column-key `RString`s on first
36
+ /// query, so repeated calls with the same statement don't re-allocate
37
+ /// column keys. The cache survives garbage collection via the custom
38
+ /// `DataTypeFunctions::mark` implementation below.
39
+ #[derive(TypedData)]
40
+ #[magnus(class = "Stoolap::PreparedStatement", free_immediately, size, mark)]
41
+ pub struct PreparedStatement {
42
+ db: Arc<ApiDatabase>,
43
+ sql_text: String,
44
+ plan: CachedPlanRef,
45
+ /// Lazily populated on first query that yields rows. `OnceLock` so
46
+ /// the init closure runs exactly once; `Opaque<RArray>` is `Send +
47
+ /// Sync` and the contained `RArray` is kept alive by the `mark`
48
+ /// callback below.
49
+ column_keys: OnceLock<Opaque<RArray>>,
50
+ }
51
+
52
+ impl DataTypeFunctions for PreparedStatement {
53
+ fn mark(&self, marker: &gc::Marker) {
54
+ if let Some(keys) = self.column_keys.get() {
55
+ marker.mark(*keys);
56
+ }
57
+ }
58
+ }
59
+
60
+ impl PreparedStatement {
61
+ pub fn new(db: Arc<ApiDatabase>, sql: &str) -> Result<Self, Error> {
62
+ let plan = db.cached_plan(sql).map_err(to_magnus)?;
63
+ Ok(Self {
64
+ db,
65
+ sql_text: sql.to_string(),
66
+ plan,
67
+ column_keys: OnceLock::new(),
68
+ })
69
+ }
70
+
71
+ pub(crate) fn plan(&self) -> &CachedPlanRef {
72
+ &self.plan
73
+ }
74
+
75
+ pub(crate) fn sql_text(&self) -> &str {
76
+ &self.sql_text
77
+ }
78
+
79
+ /// Return the cached column-key array, creating it from `rows.columns()`
80
+ /// on first call. The returned `RArray` is rooted through this struct's
81
+ /// `mark` callback and is safe to hold across subsequent Ruby calls.
82
+ fn ensure_column_keys(&self, ruby: &Ruby, rows: &Rows) -> RArray {
83
+ let opaque = self.column_keys.get_or_init(|| {
84
+ let cols = rows.columns();
85
+ let arr = ruby.ary_new_capa(cols.len());
86
+ for c in cols {
87
+ let s = ruby.str_new(c);
88
+ s.freeze();
89
+ arr.push(s).expect("push to fresh RArray cannot fail");
90
+ }
91
+ // Freeze the array itself so user code that receives it from
92
+ // query_raw cannot mutate the internal cache (P1 safety).
93
+ arr.freeze();
94
+ Opaque::from(arr)
95
+ });
96
+ ruby.get_inner(*opaque)
97
+ }
98
+ }
99
+
100
+ impl PreparedStatement {
101
+ /// Execute the prepared statement (DML). Returns rows affected.
102
+ pub fn execute(&self, args: &[Value]) -> Result<i64, Error> {
103
+ let params = parse_optional(args)?;
104
+ let bind = parse_params(params)?;
105
+ let plan = self.plan.clone();
106
+ match bind {
107
+ BindParams::Positional(p) => self.db.execute_plan(&plan, p).map_err(to_magnus),
108
+ BindParams::Named(n) => self.db.execute_named_plan(&plan, n).map_err(to_magnus),
109
+ }
110
+ }
111
+
112
+ /// Query rows using the prepared statement. Returns Array of Hashes.
113
+ pub fn query(&self, args: &[Value]) -> Result<RArray, Error> {
114
+ let params = parse_optional(args)?;
115
+ let bind = parse_params(params)?;
116
+ let plan = self.plan.clone();
117
+ let rows = match bind {
118
+ BindParams::Positional(p) => self.db.query_plan(&plan, p).map_err(to_magnus)?,
119
+ BindParams::Named(n) => self.db.query_named_plan(&plan, n).map_err(to_magnus)?,
120
+ };
121
+ let ruby = Ruby::get().expect("must hold the Ruby VM lock");
122
+ let keys = self.ensure_column_keys(&ruby, &rows);
123
+ rows_to_hashes_with_keys(&ruby, rows, keys)
124
+ }
125
+
126
+ /// Query a single row. Returns Hash or nil.
127
+ pub fn query_one(&self, args: &[Value]) -> Result<Value, Error> {
128
+ let params = parse_optional(args)?;
129
+ let bind = parse_params(params)?;
130
+ let plan = self.plan.clone();
131
+ let rows = match bind {
132
+ BindParams::Positional(p) => self.db.query_plan(&plan, p).map_err(to_magnus)?,
133
+ BindParams::Named(n) => self.db.query_named_plan(&plan, n).map_err(to_magnus)?,
134
+ };
135
+ let ruby = Ruby::get().expect("must hold the Ruby VM lock");
136
+ let keys = self.ensure_column_keys(&ruby, &rows);
137
+ first_row_to_hash_with_keys(&ruby, rows, keys)
138
+ }
139
+
140
+ /// Query rows in raw format.
141
+ pub fn query_raw(&self, args: &[Value]) -> Result<RHash, Error> {
142
+ let params = parse_optional(args)?;
143
+ let bind = parse_params(params)?;
144
+ let plan = self.plan.clone();
145
+ let rows = match bind {
146
+ BindParams::Positional(p) => self.db.query_plan(&plan, p).map_err(to_magnus)?,
147
+ BindParams::Named(n) => self.db.query_named_plan(&plan, n).map_err(to_magnus)?,
148
+ };
149
+ let ruby = Ruby::get().expect("must hold the Ruby VM lock");
150
+ let keys = self.ensure_column_keys(&ruby, &rows);
151
+ rows_to_raw_with_keys(&ruby, rows, keys)
152
+ }
153
+
154
+ /// Execute with multiple parameter sets, auto-wrapped in a transaction.
155
+ pub fn execute_batch(&self, params_list: RArray) -> Result<i64, Error> {
156
+ use stoolap::api::ParamVec;
157
+
158
+ let mut all_params: Vec<ParamVec> = Vec::with_capacity(params_list.len());
159
+ for item in params_list.into_iter() {
160
+ match parse_params(Some(item))? {
161
+ BindParams::Positional(p) => all_params.push(p),
162
+ BindParams::Named(_) => {
163
+ return Err(raise(
164
+ "execute_batch only supports positional parameters (Array)",
165
+ ));
166
+ }
167
+ }
168
+ }
169
+
170
+ let stmt = self.plan.statement.as_ref();
171
+ let mut tx = self.db.begin().map_err(to_magnus)?;
172
+ let mut total = 0i64;
173
+ for params in all_params {
174
+ total += tx.execute_prepared(stmt, params).map_err(to_magnus)?;
175
+ }
176
+ tx.commit().map_err(to_magnus)?;
177
+ Ok(total)
178
+ }
179
+
180
+ /// SQL text of this prepared statement.
181
+ pub fn sql(&self) -> String {
182
+ self.sql_text.clone()
183
+ }
184
+
185
+ pub fn inspect(&self) -> String {
186
+ format!("#<Stoolap::PreparedStatement {:?}>", self.sql_text)
187
+ }
188
+ }
189
+
190
+ fn parse_optional(args: &[Value]) -> Result<Option<Value>, Error> {
191
+ let scanned = scan_args::<(), (Option<Value>,), (), (), (), ()>(args)?;
192
+ Ok(scanned.optional.0)
193
+ }
@@ -0,0 +1,246 @@
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::sync::Mutex;
16
+
17
+ use magnus::{scan_args::scan_args, Error, RArray, RHash, Value};
18
+
19
+ use stoolap::api::Transaction as ApiTransaction;
20
+ use stoolap::CachedPlanRef;
21
+
22
+ use crate::database::{first_row_to_hash, rows_to_hashes, rows_to_raw};
23
+ use crate::error::{raise, to_magnus};
24
+ use crate::statement::PreparedStatement;
25
+ use crate::value::{parse_params, BindParams};
26
+
27
+ /// A Stoolap transaction.
28
+ ///
29
+ /// Created via `db.begin_transaction` or the `db.transaction { |tx| ... }`
30
+ /// block helper, which auto-commits on success and auto-rolls-back on
31
+ /// exception.
32
+ #[magnus::wrap(class = "Stoolap::Transaction", free_immediately, size)]
33
+ pub struct Transaction {
34
+ tx: Mutex<Option<ApiTransaction>>,
35
+ }
36
+
37
+ impl Transaction {
38
+ pub fn from_tx(tx: ApiTransaction) -> Self {
39
+ Self {
40
+ tx: Mutex::new(Some(tx)),
41
+ }
42
+ }
43
+
44
+ fn with_tx<F, R>(&self, f: F) -> Result<R, Error>
45
+ where
46
+ F: FnOnce(&mut ApiTransaction) -> Result<R, Error>,
47
+ {
48
+ let mut guard = self
49
+ .tx
50
+ .lock()
51
+ .map_err(|_| raise("Transaction lock poisoned"))?;
52
+ let tx = guard
53
+ .as_mut()
54
+ .ok_or_else(|| raise("Transaction is no longer active"))?;
55
+ f(tx)
56
+ }
57
+ }
58
+
59
+ impl Transaction {
60
+ /// Execute a DDL/DML statement within the transaction. Returns rows affected.
61
+ pub fn execute(&self, args: &[Value]) -> Result<i64, Error> {
62
+ let (sql, params) = parse_sql_args(args)?;
63
+ let bind = parse_params(params)?;
64
+ self.with_tx(|tx| match bind {
65
+ BindParams::Positional(p) => tx.execute(&sql, p).map_err(to_magnus),
66
+ BindParams::Named(n) => tx.execute_named(&sql, n).map_err(to_magnus),
67
+ })
68
+ }
69
+
70
+ /// Query rows within the transaction. Returns Array of Hashes.
71
+ pub fn query(&self, args: &[Value]) -> Result<RArray, Error> {
72
+ let (sql, params) = parse_sql_args(args)?;
73
+ let bind = parse_params(params)?;
74
+ let rows = self.with_tx(|tx| match bind {
75
+ BindParams::Positional(p) => tx.query(&sql, p).map_err(to_magnus),
76
+ BindParams::Named(n) => tx.query_named(&sql, n).map_err(to_magnus),
77
+ })?;
78
+ rows_to_hashes(rows)
79
+ }
80
+
81
+ /// Query a single row. Returns Hash or nil.
82
+ pub fn query_one(&self, args: &[Value]) -> Result<Value, Error> {
83
+ let (sql, params) = parse_sql_args(args)?;
84
+ let bind = parse_params(params)?;
85
+ let rows = self.with_tx(|tx| match bind {
86
+ BindParams::Positional(p) => tx.query(&sql, p).map_err(to_magnus),
87
+ BindParams::Named(n) => tx.query_named(&sql, n).map_err(to_magnus),
88
+ })?;
89
+ first_row_to_hash(rows)
90
+ }
91
+
92
+ /// Query rows in raw format.
93
+ pub fn query_raw(&self, args: &[Value]) -> Result<RHash, Error> {
94
+ let (sql, params) = parse_sql_args(args)?;
95
+ let bind = parse_params(params)?;
96
+ let rows = self.with_tx(|tx| match bind {
97
+ BindParams::Positional(p) => tx.query(&sql, p).map_err(to_magnus),
98
+ BindParams::Named(n) => tx.query_named(&sql, n).map_err(to_magnus),
99
+ })?;
100
+ rows_to_raw(rows)
101
+ }
102
+
103
+ /// Execute the same SQL with multiple parameter sets. Returns total rows affected.
104
+ pub fn execute_batch(&self, sql: String, params_list: RArray) -> Result<i64, Error> {
105
+ use stoolap::api::ParamVec;
106
+ use stoolap::parser::Parser;
107
+
108
+ let mut all_params: Vec<ParamVec> = Vec::with_capacity(params_list.len());
109
+ for item in params_list.into_iter() {
110
+ match parse_params(Some(item))? {
111
+ BindParams::Positional(p) => all_params.push(p),
112
+ BindParams::Named(_) => {
113
+ return Err(raise(
114
+ "execute_batch only supports positional parameters (Array)",
115
+ ));
116
+ }
117
+ }
118
+ }
119
+
120
+ let mut parser = Parser::new(&sql);
121
+ let program = parser.parse_program().map_err(|e| raise(e.to_string()))?;
122
+ if program.statements.len() > 1 {
123
+ return Err(raise(
124
+ "execute_batch accepts exactly one SQL statement; use exec() for multi-statement SQL",
125
+ ));
126
+ }
127
+ let stmt = program
128
+ .statements
129
+ .first()
130
+ .ok_or_else(|| raise("No SQL statement found"))?;
131
+
132
+ self.with_tx(|tx| {
133
+ let mut total = 0i64;
134
+ for params in all_params {
135
+ total += tx.execute_prepared(stmt, params).map_err(to_magnus)?;
136
+ }
137
+ Ok(total)
138
+ })
139
+ }
140
+
141
+ /// Execute a prepared statement within the transaction. Returns rows affected.
142
+ ///
143
+ /// Note: named parameters fall back to `tx.execute_named` which reparses
144
+ /// the SQL, because stoolap's `Transaction::execute_prepared` only
145
+ /// accepts positional `Params` and the AST encodes `:name` references
146
+ /// that cannot be resolved through positional indexing. Use positional
147
+ /// `$1, $2` params for the full prepared fast path.
148
+ pub fn execute_prepared(&self, args: &[Value]) -> Result<i64, Error> {
149
+ let (plan, sql, params) = parse_stmt_args(args)?;
150
+ let bind = parse_params(params)?;
151
+ self.with_tx(|tx| match bind {
152
+ BindParams::Positional(p) => tx
153
+ .execute_prepared(plan.statement.as_ref(), p)
154
+ .map_err(to_magnus),
155
+ BindParams::Named(n) => tx.execute_named(&sql, n).map_err(to_magnus),
156
+ })
157
+ }
158
+
159
+ /// Query rows using a prepared statement. Returns Array of Hashes.
160
+ pub fn query_prepared(&self, args: &[Value]) -> Result<RArray, Error> {
161
+ let (plan, sql, params) = parse_stmt_args(args)?;
162
+ let bind = parse_params(params)?;
163
+ let rows = self.with_tx(|tx| match bind {
164
+ BindParams::Positional(p) => tx
165
+ .query_prepared(plan.statement.as_ref(), p)
166
+ .map_err(to_magnus),
167
+ BindParams::Named(n) => tx.query_named(&sql, n).map_err(to_magnus),
168
+ })?;
169
+ rows_to_hashes(rows)
170
+ }
171
+
172
+ /// Query a single row using a prepared statement. Returns Hash or nil.
173
+ pub fn query_one_prepared(&self, args: &[Value]) -> Result<Value, Error> {
174
+ let (plan, sql, params) = parse_stmt_args(args)?;
175
+ let bind = parse_params(params)?;
176
+ let rows = self.with_tx(|tx| match bind {
177
+ BindParams::Positional(p) => tx
178
+ .query_prepared(plan.statement.as_ref(), p)
179
+ .map_err(to_magnus),
180
+ BindParams::Named(n) => tx.query_named(&sql, n).map_err(to_magnus),
181
+ })?;
182
+ first_row_to_hash(rows)
183
+ }
184
+
185
+ /// Query rows using a prepared statement in raw format.
186
+ pub fn query_raw_prepared(&self, args: &[Value]) -> Result<RHash, Error> {
187
+ let (plan, sql, params) = parse_stmt_args(args)?;
188
+ let bind = parse_params(params)?;
189
+ let rows = self.with_tx(|tx| match bind {
190
+ BindParams::Positional(p) => tx
191
+ .query_prepared(plan.statement.as_ref(), p)
192
+ .map_err(to_magnus),
193
+ BindParams::Named(n) => tx.query_named(&sql, n).map_err(to_magnus),
194
+ })?;
195
+ rows_to_raw(rows)
196
+ }
197
+
198
+ /// Commit the transaction.
199
+ pub fn commit(&self) -> Result<(), Error> {
200
+ let mut guard = self
201
+ .tx
202
+ .lock()
203
+ .map_err(|_| raise("Transaction lock poisoned"))?;
204
+ let mut tx = guard
205
+ .take()
206
+ .ok_or_else(|| raise("Transaction is no longer active"))?;
207
+ tx.commit().map_err(to_magnus)
208
+ }
209
+
210
+ /// Rollback the transaction.
211
+ pub fn rollback(&self) -> Result<(), Error> {
212
+ let mut guard = self
213
+ .tx
214
+ .lock()
215
+ .map_err(|_| raise("Transaction lock poisoned"))?;
216
+ let mut tx = guard
217
+ .take()
218
+ .ok_or_else(|| raise("Transaction is no longer active"))?;
219
+ tx.rollback().map_err(to_magnus)
220
+ }
221
+
222
+ pub fn inspect(&self) -> String {
223
+ let active = self.tx.lock().map(|g| g.is_some()).unwrap_or(false);
224
+ if active {
225
+ "#<Stoolap::Transaction active>".to_string()
226
+ } else {
227
+ "#<Stoolap::Transaction closed>".to_string()
228
+ }
229
+ }
230
+ }
231
+
232
+ fn parse_sql_args(args: &[Value]) -> Result<(String, Option<Value>), Error> {
233
+ let scanned = scan_args::<(String,), (Option<Value>,), (), (), (), ()>(args)?;
234
+ Ok((scanned.required.0, scanned.optional.0))
235
+ }
236
+
237
+ fn parse_stmt_args(args: &[Value]) -> Result<(CachedPlanRef, String, Option<Value>), Error> {
238
+ use magnus::TryConvert;
239
+ let scanned = scan_args::<(Value,), (Option<Value>,), (), (), (), ()>(args)?;
240
+ let stmt: &PreparedStatement = TryConvert::try_convert(scanned.required.0)?;
241
+ Ok((
242
+ stmt.plan().clone(),
243
+ stmt.sql_text().to_string(),
244
+ scanned.optional.0,
245
+ ))
246
+ }