1use std::io::{BufRead, BufReader, Stdin, Write};
19use std::panic::AssertUnwindSafe;
20
21use tokio::sync::Mutex as AsyncMutex;
22
23use crate::application::entity::{CreateRowInput, CreateRowsBatchInput};
24use crate::application::ports::RuntimeEntityPort;
25use crate::json::{self as json, Value};
26use crate::runtime::query_request::{
27 ParamValue, PreparedId, PreparedRegistry, QueryRequest, QueryRequestExecutor,
28};
29use crate::runtime::{RedDBRuntime, RuntimeQueryResult};
30use crate::storage::query::unified::UnifiedRecord;
31use reddb_grpc_proto::RedDBClient;
32use reddb_types::Value as SchemaValue;
33
34enum Backend<'a> {
47 Local(&'a RedDBRuntime),
48 Remote(Box<RemoteBackend<'a>>),
49}
50
51struct RemoteBackend<'a> {
52 client: AsyncMutex<RedDBClient>,
53 tokio_rt: &'a tokio::runtime::Runtime,
54}
55
56pub const PROTOCOL_VERSION: &str = "1.0";
58const STDIO_BULK_INSERT_CHUNK_ROWS: usize = 500;
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub(crate) enum StdioRemoteDisposition {
63 Served,
64 Unsupported { error_code: &'static str },
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub(crate) struct StdioMethodCatalogEntry {
70 pub(crate) method: &'static str,
71 pub(crate) command_id: &'static str,
72 pub(crate) remote: StdioRemoteDisposition,
73}
74
75const fn stdio_method(
76 method: &'static str,
77 command_id: &'static str,
78 remote: StdioRemoteDisposition,
79) -> StdioMethodCatalogEntry {
80 StdioMethodCatalogEntry {
81 method,
82 command_id,
83 remote,
84 }
85}
86
87const STDIO_METHOD_CATALOG: [StdioMethodCatalogEntry; 16] = [
91 stdio_method(
92 "tx.begin",
93 "query.execute",
94 StdioRemoteDisposition::Unsupported {
95 error_code: error_code::TX_NOT_SUPPORTED_REMOTE,
96 },
97 ),
98 stdio_method(
99 "tx.commit",
100 "query.execute",
101 StdioRemoteDisposition::Unsupported {
102 error_code: error_code::TX_NOT_SUPPORTED_REMOTE,
103 },
104 ),
105 stdio_method(
106 "query.open",
107 "streams.query.output",
108 StdioRemoteDisposition::Unsupported {
109 error_code: error_code::CURSOR_NOT_SUPPORTED_REMOTE,
110 },
111 ),
112 stdio_method(
113 "query.next",
114 "streams.query.output",
115 StdioRemoteDisposition::Unsupported {
116 error_code: error_code::CURSOR_NOT_SUPPORTED_REMOTE,
117 },
118 ),
119 stdio_method(
120 "query.close",
121 "streams.query.cancel",
122 StdioRemoteDisposition::Unsupported {
123 error_code: error_code::CURSOR_NOT_SUPPORTED_REMOTE,
124 },
125 ),
126 stdio_method(
127 "tx.rollback",
128 "query.execute",
129 StdioRemoteDisposition::Unsupported {
130 error_code: error_code::TX_NOT_SUPPORTED_REMOTE,
131 },
132 ),
133 stdio_method("version", "health.live", StdioRemoteDisposition::Served),
134 stdio_method(
135 "health",
136 "ops.health.aggregate",
137 StdioRemoteDisposition::Served,
138 ),
139 stdio_method("query", "query.execute", StdioRemoteDisposition::Served),
140 stdio_method(
141 "prepare",
142 "query.execute",
143 StdioRemoteDisposition::Unsupported {
144 error_code: error_code::PREPARED_NOT_SUPPORTED_REMOTE,
145 },
146 ),
147 stdio_method(
148 "execute_prepared",
149 "query.execute",
150 StdioRemoteDisposition::Unsupported {
151 error_code: error_code::PREPARED_NOT_SUPPORTED_REMOTE,
152 },
153 ),
154 stdio_method(
155 "insert",
156 "collections.rows.create",
157 StdioRemoteDisposition::Served,
158 ),
159 stdio_method(
160 "bulk_insert",
161 "collections.bulk.rows",
162 StdioRemoteDisposition::Served,
163 ),
164 stdio_method(
165 "get",
166 "collections.entities.get",
167 StdioRemoteDisposition::Served,
168 ),
169 stdio_method(
170 "delete",
171 "collections.entities.delete",
172 StdioRemoteDisposition::Served,
173 ),
174 stdio_method("close", "health.live", StdioRemoteDisposition::Served),
175];
176
177pub(crate) fn stdio_method_catalog() -> &'static [StdioMethodCatalogEntry] {
178 &STDIO_METHOD_CATALOG
179}
180
181pub mod error_code {
183 pub const PARSE_ERROR: &str = "PARSE_ERROR";
184 pub const INVALID_REQUEST: &str = "INVALID_REQUEST";
185 pub const INVALID_PARAMS: &str = "INVALID_PARAMS";
186 pub const QUERY_ERROR: &str = "QUERY_ERROR";
187 pub const NOT_FOUND: &str = "NOT_FOUND";
188 pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
189 pub const TX_ALREADY_OPEN: &str = "TX_ALREADY_OPEN";
192 pub const NO_TX_OPEN: &str = "NO_TX_OPEN";
195 pub const TX_REPLAY_FAILED: &str = "TX_REPLAY_FAILED";
199 pub const TX_NOT_SUPPORTED_REMOTE: &str = "TX_NOT_SUPPORTED_REMOTE";
201 pub const CURSOR_NOT_SUPPORTED_REMOTE: &str = "CURSOR_NOT_SUPPORTED_REMOTE";
203 pub const PREPARED_NOT_SUPPORTED_REMOTE: &str = "PREPARED_NOT_SUPPORTED_REMOTE";
205 pub const CURSOR_NOT_FOUND: &str = "CURSOR_NOT_FOUND";
209 pub const CURSOR_LIMIT_EXCEEDED: &str = "CURSOR_LIMIT_EXCEEDED";
211}
212
213pub(crate) const MAX_CURSORS_PER_SESSION: usize = 64;
217pub(crate) const DEFAULT_CURSOR_BATCH_SIZE: usize = 100;
221pub(crate) const MAX_CURSOR_BATCH_SIZE: usize = 10_000;
224
225pub(crate) struct Session {
245 next_tx_id: u64,
246 current_tx: Option<OpenTx>,
247 next_cursor_id: u64,
248 cursors: std::collections::HashMap<u64, Cursor>,
249 prepared: PreparedRegistry,
251}
252
253impl Session {
254 pub(crate) fn new() -> Self {
255 Self {
256 next_tx_id: 1,
257 current_tx: None,
258 next_cursor_id: 1,
259 cursors: std::collections::HashMap::new(),
260 prepared: PreparedRegistry::new(),
261 }
262 }
263
264 fn open_tx(&mut self) -> Result<u64, (&'static str, String)> {
265 if let Some(tx) = &self.current_tx {
266 return Err((
267 error_code::TX_ALREADY_OPEN,
268 format!("transaction {} already open in this session", tx.tx_id),
269 ));
270 }
271 let tx_id = self.next_tx_id;
272 self.next_tx_id = self.next_tx_id.saturating_add(1);
273 self.current_tx = Some(OpenTx {
274 tx_id,
275 write_set: Vec::new(),
276 });
277 Ok(tx_id)
278 }
279
280 fn take_tx(&mut self) -> Option<OpenTx> {
281 self.current_tx.take()
282 }
283
284 fn current_tx_mut(&mut self) -> Option<&mut OpenTx> {
285 self.current_tx.as_mut()
286 }
287
288 #[allow(dead_code)]
289 fn has_tx(&self) -> bool {
290 self.current_tx.is_some()
291 }
292
293 fn insert_cursor(&mut self, cursor: Cursor) -> Result<u64, (&'static str, String)> {
296 if self.cursors.len() >= MAX_CURSORS_PER_SESSION {
297 return Err((
298 error_code::CURSOR_LIMIT_EXCEEDED,
299 format!(
300 "session already holds {} cursors (max {}) — close some before opening new ones",
301 self.cursors.len(),
302 MAX_CURSORS_PER_SESSION
303 ),
304 ));
305 }
306 let id = self.next_cursor_id;
307 self.next_cursor_id = self.next_cursor_id.saturating_add(1);
308 let mut cursor = cursor;
309 cursor.cursor_id = id;
310 self.cursors.insert(id, cursor);
311 Ok(id)
312 }
313
314 fn cursor_mut(&mut self, id: u64) -> Option<&mut Cursor> {
315 self.cursors.get_mut(&id)
316 }
317
318 fn drop_cursor(&mut self, id: u64) -> Option<Cursor> {
319 self.cursors.remove(&id)
320 }
321
322 fn clear_cursors(&mut self) {
323 self.cursors.clear();
324 }
325}
326
327impl Default for Session {
328 fn default() -> Self {
329 Self::new()
330 }
331}
332
333struct OpenTx {
335 tx_id: u64,
336 write_set: Vec<PendingSql>,
337}
338
339enum PendingSql {
343 Insert(String),
344 Delete(String),
345 #[allow(dead_code)] Update(String),
347}
348
349impl PendingSql {
350 fn sql(&self) -> &str {
351 match self {
352 PendingSql::Insert(s) | PendingSql::Delete(s) | PendingSql::Update(s) => s,
353 }
354 }
355}
356
357pub(crate) struct Cursor {
369 cursor_id: u64,
370 columns: Vec<String>,
371 rows: Vec<UnifiedRecord>,
372 position: usize,
373}
374
375impl Cursor {
376 fn new(columns: Vec<String>, rows: Vec<UnifiedRecord>) -> Self {
377 Self {
378 cursor_id: 0, columns,
380 rows,
381 position: 0,
382 }
383 }
384
385 fn total(&self) -> usize {
386 self.rows.len()
387 }
388
389 fn remaining(&self) -> usize {
390 self.rows.len().saturating_sub(self.position)
391 }
392
393 fn is_exhausted(&self) -> bool {
394 self.position >= self.rows.len()
395 }
396
397 fn take_batch(&mut self, batch_size: usize) -> &[UnifiedRecord] {
400 let end = (self.position + batch_size).min(self.rows.len());
401 let slice = &self.rows[self.position..end];
402 self.position = end;
403 slice
404 }
405}
406
407pub fn run(runtime: &RedDBRuntime) -> i32 {
413 run_with_io(runtime, std::io::stdin(), &mut std::io::stdout())
414}
415
416pub fn run_remote(endpoint: &str, token: Option<String>) -> i32 {
423 let tokio_rt = match tokio::runtime::Builder::new_current_thread()
424 .enable_all()
425 .build()
426 {
427 Ok(rt) => rt,
428 Err(e) => {
429 tracing::error!(err = %e, "rpc: failed to build tokio runtime");
430 return 1;
431 }
432 };
433 let client = match tokio_rt.block_on(RedDBClient::connect(endpoint, token)) {
434 Ok(c) => c,
435 Err(e) => {
436 tracing::error!(endpoint, err = %e, "rpc: failed to connect");
437 return 1;
438 }
439 };
440 let backend = Backend::Remote(Box::new(RemoteBackend {
441 client: AsyncMutex::new(client),
442 tokio_rt: &tokio_rt,
443 }));
444 run_backend(&backend, std::io::stdin(), &mut std::io::stdout())
445}
446
447pub fn run_with_io<W: Write>(runtime: &RedDBRuntime, stdin: Stdin, stdout: &mut W) -> i32 {
449 run_backend(&Backend::Local(runtime), stdin, stdout)
450}
451
452pub struct EmbeddedRpcSession {
466 runtime: std::sync::Arc<RedDBRuntime>,
467 session: Session,
468 conn_id: u64,
469}
470
471impl EmbeddedRpcSession {
472 pub fn new(runtime: std::sync::Arc<RedDBRuntime>) -> Self {
473 Self {
474 runtime,
475 session: Session::new(),
476 conn_id: next_stdio_conn_id(),
477 }
478 }
479
480 pub fn handle(&mut self, line: &str) -> (String, bool) {
485 crate::runtime::impl_core::set_current_connection_id(self.conn_id);
486 let response = handle_line(&Backend::Local(&self.runtime), &mut self.session, line);
487 crate::runtime::impl_core::clear_current_connection_id();
488 let closed = response.contains("\"__close__\":true");
489 (response, closed)
490 }
491}
492
493impl Drop for EmbeddedRpcSession {
494 fn drop(&mut self) {
495 let _ = self.session.take_tx();
497 }
498}
499
500static STDIO_SESSION_CONN_ID: std::sync::atomic::AtomicU64 =
507 std::sync::atomic::AtomicU64::new(1_000_000);
508
509fn next_stdio_conn_id() -> u64 {
510 STDIO_SESSION_CONN_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
511}
512
513fn run_backend<W: Write>(backend: &Backend<'_>, stdin: Stdin, stdout: &mut W) -> i32 {
514 let reader = BufReader::new(stdin.lock());
515 let mut session = Session::new();
516 let conn_id = next_stdio_conn_id();
520 crate::runtime::impl_core::set_current_connection_id(conn_id);
521 for line_result in reader.lines() {
522 let line = match line_result {
523 Ok(l) => l,
524 Err(e) => {
525 let _ = writeln!(
526 stdout,
527 "{}",
528 error_response(&Value::Null, error_code::INTERNAL_ERROR, &e.to_string())
529 );
530 let _ = stdout.flush();
531 return 1;
532 }
533 };
534 if line.trim().is_empty() {
535 continue;
536 }
537 let response = handle_line(backend, &mut session, &line);
538 if writeln!(stdout, "{}", response).is_err() || stdout.flush().is_err() {
539 return 1;
540 }
541 if response.contains("\"__close__\":true") {
542 return 0;
543 }
544 }
545 let _ = session.take_tx();
549 crate::runtime::impl_core::clear_current_connection_id();
550 0
551}
552
553fn handle_line(backend: &Backend<'_>, session: &mut Session, line: &str) -> String {
557 let parsed: Value = match json::from_str(line) {
558 Ok(v) => v,
559 Err(err) => {
560 return error_response(
561 &Value::Null,
562 error_code::PARSE_ERROR,
563 &format!("invalid JSON: {err}"),
564 );
565 }
566 };
567
568 let id = parsed.get("id").cloned().unwrap_or(Value::Null);
569
570 let method = match parsed.get("method").and_then(Value::as_str) {
571 Some(m) => m.to_string(),
572 None => {
573 return error_response(&id, error_code::INVALID_REQUEST, "missing 'method' field");
574 }
575 };
576
577 let params = parsed.get("params").cloned().unwrap_or(Value::Null);
578
579 let dispatch = std::panic::catch_unwind(AssertUnwindSafe(|| {
580 let entry = stdio_method_catalog()
581 .iter()
582 .find(|entry| entry.method == method);
583 if !method.starts_with("auth.") && entry.is_none() {
584 return Err((
585 error_code::INVALID_REQUEST,
586 format!("unknown method: {method}"),
587 ));
588 }
589 if let Some(entry) = entry {
590 if crate::server::command_catalog()
591 .command(entry.command_id)
592 .is_none()
593 {
594 return Err((
595 error_code::INTERNAL_ERROR,
596 format!(
597 "stdio method {method} names missing catalog command {}",
598 entry.command_id
599 ),
600 ));
601 }
602 }
603
604 match backend {
605 Backend::Local(rt) => dispatch_method(rt, session, &method, ¶ms),
606 Backend::Remote(remote) => match entry.map(|entry| entry.remote) {
607 Some(StdioRemoteDisposition::Unsupported { error_code }) => Err((
608 error_code,
609 format!("{method} is not supported over remote gRPC yet"),
610 )),
611 Some(StdioRemoteDisposition::Served) | None => {
612 dispatch_method_remote(&remote.client, remote.tokio_rt, &method, ¶ms)
613 }
614 },
615 }
616 }));
617
618 match dispatch {
619 Ok(Ok(result)) => success_response(&id, &result, method == "close"),
620 Ok(Err((code, msg))) => error_response(&id, code, &msg),
621 Err(_) => error_response(&id, error_code::INTERNAL_ERROR, "handler panicked (caught)"),
622 }
623}
624
625fn dispatch_method(
628 runtime: &RedDBRuntime,
629 session: &mut Session,
630 method: &str,
631 params: &Value,
632) -> Result<Value, (&'static str, String)> {
633 match method {
634 "tx.begin" => {
635 let tx_id = session.open_tx()?;
636 Ok(Value::Object(
637 [
638 ("tx_id".to_string(), Value::Number(tx_id as f64)),
639 (
640 "isolation".to_string(),
641 Value::String("read_committed_deferred".to_string()),
642 ),
643 ]
644 .into_iter()
645 .collect(),
646 ))
647 }
648
649 "tx.commit" => {
650 let tx = session.take_tx().ok_or((
651 error_code::NO_TX_OPEN,
652 "no transaction is open in this session".to_string(),
653 ))?;
654 let tx_id = tx.tx_id;
655 let op_count = tx.write_set.len();
656
657 let replay: Result<(u64, usize), (usize, String)> = (|| {
663 runtime
664 .execute_query("BEGIN")
665 .map_err(|e| (0usize, format!("BEGIN: {e}")))?;
666 let mut total_affected: u64 = 0;
667 for (idx, op) in tx.write_set.iter().enumerate() {
668 match runtime.execute_query(op.sql()) {
669 Ok(qr) => total_affected += qr.affected_rows,
670 Err(e) => {
671 let _ = runtime.execute_query("ROLLBACK");
672 return Err((idx, e.to_string()));
673 }
674 }
675 }
676 runtime
677 .execute_query("COMMIT")
678 .map_err(|e| (op_count, format!("COMMIT: {e}")))?;
679 Ok((total_affected, op_count))
680 })();
681
682 match replay {
683 Ok((affected, replayed)) => Ok(Value::Object(
684 [
685 ("tx_id".to_string(), Value::Number(tx_id as f64)),
686 ("ops_replayed".to_string(), Value::Number(replayed as f64)),
687 ("affected".to_string(), Value::Number(affected as f64)),
688 ]
689 .into_iter()
690 .collect(),
691 )),
692 Err((failed_idx, msg)) => Err((
693 error_code::TX_REPLAY_FAILED,
694 format!(
695 "tx {tx_id} replay failed at op {failed_idx}/{op_count}: {msg} \
696 (ops 0..{failed_idx} already applied and are NOT rolled back)"
697 ),
698 )),
699 }
700 }
701
702 "query.open" => {
703 let sql = params.get("sql").and_then(Value::as_str).ok_or((
704 error_code::INVALID_PARAMS,
705 "missing 'sql' string".to_string(),
706 ))?;
707 let qr = runtime
708 .execute_query(sql)
709 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
710
711 let columns: Vec<String> = qr
715 .result
716 .records
717 .first()
718 .map(|first| {
719 let mut keys: Vec<String> = first
720 .column_names()
721 .into_iter()
722 .map(|k| k.to_string())
723 .collect();
724 keys.sort();
725 keys
726 })
727 .unwrap_or_default();
728
729 let cursor = Cursor::new(columns.clone(), qr.result.records);
730 let total = cursor.total();
731 let cursor_id = session.insert_cursor(cursor)?;
732
733 Ok(Value::Object(
734 [
735 ("cursor_id".to_string(), Value::Number(cursor_id as f64)),
736 (
737 "columns".to_string(),
738 Value::Array(columns.into_iter().map(Value::String).collect()),
739 ),
740 ("total_rows".to_string(), Value::Number(total as f64)),
741 ]
742 .into_iter()
743 .collect(),
744 ))
745 }
746
747 "query.next" => {
748 let cursor_id = params
749 .get("cursor_id")
750 .and_then(|v| v.as_f64())
751 .map(|n| n as u64)
752 .ok_or((
753 error_code::INVALID_PARAMS,
754 "missing 'cursor_id' number".to_string(),
755 ))?;
756 let batch_size = params
757 .get("batch_size")
758 .and_then(|v| v.as_f64())
759 .map(|n| n as usize)
760 .unwrap_or(DEFAULT_CURSOR_BATCH_SIZE)
761 .clamp(1, MAX_CURSOR_BATCH_SIZE);
762
763 let (rows, done, remaining) = {
766 let cursor = session.cursor_mut(cursor_id).ok_or((
767 error_code::CURSOR_NOT_FOUND,
768 format!("cursor {cursor_id} not found"),
769 ))?;
770 let batch = cursor.take_batch(batch_size);
771 let rows_json: Vec<Value> = batch.iter().map(record_to_json_object).collect();
772 (rows_json, cursor.is_exhausted(), cursor.remaining())
773 };
774
775 if done {
776 let _ = session.drop_cursor(cursor_id);
779 }
780
781 Ok(Value::Object(
782 [
783 ("cursor_id".to_string(), Value::Number(cursor_id as f64)),
784 ("rows".to_string(), Value::Array(rows)),
785 ("done".to_string(), Value::Bool(done)),
786 ("remaining".to_string(), Value::Number(remaining as f64)),
787 ]
788 .into_iter()
789 .collect(),
790 ))
791 }
792
793 "query.close" => {
794 let cursor_id = params
795 .get("cursor_id")
796 .and_then(|v| v.as_f64())
797 .map(|n| n as u64)
798 .ok_or((
799 error_code::INVALID_PARAMS,
800 "missing 'cursor_id' number".to_string(),
801 ))?;
802 let existed = session.drop_cursor(cursor_id).is_some();
803 if !existed {
804 return Err((
805 error_code::CURSOR_NOT_FOUND,
806 format!("cursor {cursor_id} not found"),
807 ));
808 }
809 Ok(Value::Object(
810 [
811 ("cursor_id".to_string(), Value::Number(cursor_id as f64)),
812 ("closed".to_string(), Value::Bool(true)),
813 ]
814 .into_iter()
815 .collect(),
816 ))
817 }
818
819 "tx.rollback" => {
820 let tx = session.take_tx().ok_or((
821 error_code::NO_TX_OPEN,
822 "no transaction is open in this session".to_string(),
823 ))?;
824 let ops_discarded = tx.write_set.len();
825 Ok(Value::Object(
826 [
827 ("tx_id".to_string(), Value::Number(tx.tx_id as f64)),
828 (
829 "ops_discarded".to_string(),
830 Value::Number(ops_discarded as f64),
831 ),
832 ]
833 .into_iter()
834 .collect(),
835 ))
836 }
837
838 "version" => Ok(Value::Object(
839 [
840 (
841 "version".to_string(),
842 Value::String(env!("CARGO_PKG_VERSION").to_string()),
843 ),
844 (
845 "protocol".to_string(),
846 Value::String(PROTOCOL_VERSION.to_string()),
847 ),
848 ]
849 .into_iter()
850 .collect(),
851 )),
852
853 "health" => Ok(Value::Object(
854 [
855 ("ok".to_string(), Value::Bool(true)),
856 (
857 "version".to_string(),
858 Value::String(env!("CARGO_PKG_VERSION").to_string()),
859 ),
860 ]
861 .into_iter()
862 .collect(),
863 )),
864
865 "query" => {
866 let sql = params.get("sql").and_then(Value::as_str).ok_or((
867 error_code::INVALID_PARAMS,
868 "missing 'sql' string".to_string(),
869 ))?;
870
871 let bind_values: Vec<ParamValue> = params
874 .get("params")
875 .map(|v| {
876 v.as_array()
877 .ok_or((
878 error_code::INVALID_PARAMS,
879 "'params' must be an array".to_string(),
880 ))
881 .and_then(|arr| {
882 arr.iter()
883 .map(ParamValue::decode_json)
884 .collect::<Result<Vec<_>, _>>()
885 .map_err(|message| (error_code::INVALID_PARAMS, message))
886 })
887 })
888 .transpose()?
889 .unwrap_or_default();
890
891 let qr = QueryRequestExecutor::new(runtime, &session.prepared)
892 .execute(QueryRequest::sql(sql, bind_values))
893 .map_err(|error| {
894 if matches!(error, crate::api::RedDBError::Validation { .. }) {
895 (error_code::INVALID_PARAMS, error.to_string())
896 } else {
897 (error_code::QUERY_ERROR, error.to_string())
898 }
899 })?;
900 Ok(query_result_to_json(&qr))
901 }
902
903 "prepare" => {
912 let sql = params.get("sql").and_then(Value::as_str).ok_or((
913 error_code::INVALID_PARAMS,
914 "missing 'sql' string".to_string(),
915 ))?;
916 let prepared = session
917 .prepared
918 .prepare_auto_parameterized(runtime, sql)
919 .map_err(|error| (error_code::QUERY_ERROR, error.to_string()))?;
920 Ok(Value::Object(
921 [
922 (
923 "prepared_id".to_string(),
924 Value::Number(prepared.id.into_raw() as f64),
925 ),
926 (
927 "parameter_count".to_string(),
928 Value::Number(prepared.parameter_count as f64),
929 ),
930 ]
931 .into_iter()
932 .collect(),
933 ))
934 }
935
936 "execute_prepared" => {
937 let id = params
938 .get("prepared_id")
939 .and_then(Value::as_f64)
940 .map(|n| n as u64)
941 .ok_or((
942 error_code::INVALID_PARAMS,
943 "missing 'prepared_id'".to_string(),
944 ))?;
945
946 let binds_json: Vec<Value> = params
948 .get("binds")
949 .and_then(Value::as_array)
950 .map(|a| a.to_vec())
951 .unwrap_or_default();
952 let binds: Vec<ParamValue> = binds_json
953 .iter()
954 .map(ParamValue::decode_json)
955 .collect::<Result<Vec<_>, _>>()
956 .map_err(|message| (error_code::INVALID_PARAMS, message))?;
957
958 let qr = QueryRequestExecutor::new(runtime, &session.prepared)
959 .execute(QueryRequest::prepared(PreparedId::from_raw(id), binds))
960 .map_err(|error| {
961 if matches!(error, crate::api::RedDBError::Validation { .. }) {
962 (error_code::INVALID_PARAMS, error.to_string())
963 } else {
964 (error_code::QUERY_ERROR, error.to_string())
965 }
966 })?;
967 Ok(query_result_to_json(&qr))
968 }
969
970 "insert" => {
971 let collection = params.get("collection").and_then(Value::as_str).ok_or((
972 error_code::INVALID_PARAMS,
973 "missing 'collection' string".to_string(),
974 ))?;
975 let payload = params.get("payload").ok_or((
976 error_code::INVALID_PARAMS,
977 "missing 'payload' object".to_string(),
978 ))?;
979 let payload_obj = payload.as_object().ok_or((
980 error_code::INVALID_PARAMS,
981 "'payload' must be a JSON object".to_string(),
982 ))?;
983 if let Some(tx) = session.current_tx_mut() {
984 ensure_sql_collection(collection).map_err(|m| (error_code::INVALID_PARAMS, m))?;
985 let sql = build_insert_sql(collection, payload_obj.iter());
986 tx.write_set.push(PendingSql::Insert(sql));
987 return Ok(pending_tx_response(tx.tx_id));
988 }
989
990 let output = runtime
991 .create_row(flat_payload_to_row_input(collection, payload_obj))
992 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
993 let mut out = json::Map::new();
994 out.insert("affected".to_string(), Value::Number(1.0));
995 out.insert("id".to_string(), Value::String(output.id.raw().to_string()));
996 Ok(Value::Object(out))
997 }
998
999 "bulk_insert" => {
1000 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1001 error_code::INVALID_PARAMS,
1002 "missing 'collection' string".to_string(),
1003 ))?;
1004 let payloads = params.get("payloads").and_then(Value::as_array).ok_or((
1005 error_code::INVALID_PARAMS,
1006 "missing 'payloads' array".to_string(),
1007 ))?;
1008
1009 let mut objects = Vec::with_capacity(payloads.len());
1010 for entry in payloads {
1011 objects.push(entry.as_object().ok_or((
1012 error_code::INVALID_PARAMS,
1013 "each payload must be a JSON object".to_string(),
1014 ))?);
1015 }
1016
1017 if let Some(tx) = session.current_tx_mut() {
1018 let mut buffered: u64 = 0;
1019 for obj in &objects {
1020 let sql = build_insert_sql(collection, obj.iter());
1021 tx.write_set.push(PendingSql::Insert(sql));
1022 buffered += 1;
1023 }
1024 let tx_id = tx.tx_id;
1025 return Ok(Value::Object(
1026 [
1027 ("affected".to_string(), Value::Number(0.0)),
1028 ("buffered".to_string(), Value::Number(buffered as f64)),
1029 ("pending".to_string(), Value::Bool(true)),
1030 ("tx_id".to_string(), Value::Number(tx_id as f64)),
1031 ]
1032 .into_iter()
1033 .collect(),
1034 ));
1035 }
1036
1037 if should_bulk_insert_graph(runtime, collection, &objects) {
1038 return bulk_insert_graph(runtime, collection, &objects)
1039 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()));
1040 }
1041
1042 let mut total_affected: u64 = 0;
1043 let mut ids = Vec::with_capacity(objects.len());
1044 for chunk in objects.chunks(STDIO_BULK_INSERT_CHUNK_ROWS) {
1045 let rows = chunk
1046 .iter()
1047 .map(|obj| flat_payload_to_row_input(collection, obj))
1048 .collect();
1049 let outputs = runtime
1050 .create_rows_batch(CreateRowsBatchInput {
1051 collection: collection.to_string(),
1052 rows,
1053 suppress_events: false,
1054 })
1055 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1056 total_affected += outputs.len() as u64;
1057 ids.extend(
1058 outputs
1059 .into_iter()
1060 .map(|output| Value::String(output.id.raw().to_string())),
1061 );
1062 }
1063 let mut out = json::Map::new();
1064 out.insert("affected".to_string(), Value::Number(total_affected as f64));
1065 out.insert("ids".to_string(), Value::Array(ids));
1066 Ok(Value::Object(out))
1067 }
1068
1069 "get" => {
1070 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1071 error_code::INVALID_PARAMS,
1072 "missing 'collection' string".to_string(),
1073 ))?;
1074 let id = params.get("id").and_then(Value::as_str).ok_or((
1075 error_code::INVALID_PARAMS,
1076 "missing 'id' string".to_string(),
1077 ))?;
1078 ensure_sql_collection(collection).map_err(|m| (error_code::INVALID_PARAMS, m))?;
1079 let Some(rid) = rid_from_str(id) else {
1080 return Ok(Value::Object(
1081 [("entity".to_string(), Value::Null)].into_iter().collect(),
1082 ));
1083 };
1084 let sql = format!("SELECT * FROM {collection} WHERE rid = {rid} LIMIT 1");
1085 let qr = runtime
1086 .execute_query(&sql)
1087 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1088 let entity = qr
1089 .result
1090 .records
1091 .first()
1092 .map(record_to_json_object)
1093 .unwrap_or(Value::Null);
1094 Ok(Value::Object(
1095 [("entity".to_string(), entity)].into_iter().collect(),
1096 ))
1097 }
1098
1099 "delete" => {
1100 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1101 error_code::INVALID_PARAMS,
1102 "missing 'collection' string".to_string(),
1103 ))?;
1104 let id = params.get("id").and_then(Value::as_str).ok_or((
1105 error_code::INVALID_PARAMS,
1106 "missing 'id' string".to_string(),
1107 ))?;
1108 ensure_sql_collection(collection).map_err(|m| (error_code::INVALID_PARAMS, m))?;
1109 let Some(rid) = rid_from_str(id) else {
1110 return Ok(Value::Object(
1111 [("affected".to_string(), Value::Number(0.0))]
1112 .into_iter()
1113 .collect(),
1114 ));
1115 };
1116 let sql = format!("DELETE FROM {collection} WHERE rid = {rid}");
1117
1118 if let Some(tx) = session.current_tx_mut() {
1119 tx.write_set.push(PendingSql::Delete(sql));
1120 return Ok(pending_tx_response(tx.tx_id));
1121 }
1122
1123 let qr = runtime
1124 .execute_query(&sql)
1125 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1126 Ok(Value::Object(
1127 [(
1128 "affected".to_string(),
1129 Value::Number(qr.affected_rows as f64),
1130 )]
1131 .into_iter()
1132 .collect(),
1133 ))
1134 }
1135
1136 "close" => {
1137 let _ = session.take_tx();
1142 session.clear_cursors();
1143 let _ = runtime.checkpoint();
1144 Ok(Value::Null)
1145 }
1146
1147 "auth.login"
1152 | "auth.whoami"
1153 | "auth.change_password"
1154 | "auth.create_api_key"
1155 | "auth.revoke_api_key" => {
1156 let _ = (session, params);
1157 Err((
1158 error_code::INVALID_REQUEST,
1159 format!(
1160 "{method}: auth methods are only available on grpc:// connections; \
1161 embedded modes (memory://, file://) inherit caller privileges"
1162 ),
1163 ))
1164 }
1165
1166 other => Err((
1167 error_code::INVALID_REQUEST,
1168 format!("unknown method: {other}"),
1169 )),
1170 }
1171}
1172
1173fn success_response(id: &Value, result: &Value, is_close: bool) -> String {
1178 let mut envelope = json::Map::new();
1183 envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1184 envelope.insert("id".to_string(), id.clone());
1185 envelope.insert("result".to_string(), result.clone());
1186 if is_close {
1187 envelope.insert("__close__".to_string(), Value::Bool(true));
1188 }
1189 Value::Object(envelope).to_string_compact()
1190}
1191
1192fn error_response(id: &Value, code: &str, message: &str) -> String {
1193 let mut err = json::Map::new();
1194 err.insert("code".to_string(), Value::String(code.to_string()));
1195 err.insert("message".to_string(), Value::String(message.to_string()));
1196 err.insert("data".to_string(), Value::Null);
1197
1198 let mut envelope = json::Map::new();
1199 envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1200 envelope.insert("id".to_string(), id.clone());
1201 envelope.insert("error".to_string(), Value::Object(err));
1202 Value::Object(envelope).to_string_compact()
1203}
1204
1205fn pending_tx_response(tx_id: u64) -> Value {
1212 Value::Object(
1213 [
1214 ("affected".to_string(), Value::Number(0.0)),
1215 ("pending".to_string(), Value::Bool(true)),
1216 ("tx_id".to_string(), Value::Number(tx_id as f64)),
1217 ]
1218 .into_iter()
1219 .collect(),
1220 )
1221}
1222
1223pub(crate) fn ensure_sql_collection(collection: &str) -> Result<(), String> {
1228 if crate::server::handlers_query::is_safe_sql_identifier(collection) {
1229 Ok(())
1230 } else {
1231 Err(format!(
1232 "invalid collection name '{collection}': expected [A-Za-z_][A-Za-z0-9_]*"
1233 ))
1234 }
1235}
1236
1237pub(crate) fn rid_from_str(id: &str) -> Option<u64> {
1241 id.parse::<u64>().ok()
1242}
1243
1244pub(crate) fn build_insert_sql<'a, I>(collection: &str, fields: I) -> String
1245where
1246 I: Iterator<Item = (&'a String, &'a Value)>,
1247{
1248 let mut cols = Vec::new();
1249 let mut vals = Vec::new();
1250 for (k, v) in fields {
1251 cols.push(k.clone());
1252 vals.push(value_to_sql_literal(v));
1253 }
1254 format!(
1255 "INSERT INTO {collection} ({}) VALUES ({})",
1256 cols.join(", "),
1257 vals.join(", "),
1258 )
1259}
1260
1261fn flat_payload_to_row_input(
1262 collection: &str,
1263 payload: &json::Map<String, Value>,
1264) -> CreateRowInput {
1265 CreateRowInput {
1266 collection: collection.to_string(),
1267 fields: payload
1268 .iter()
1269 .map(|(key, value)| {
1270 (
1271 key.clone(),
1272 SchemaValue::from(ParamValue::decode_json_lossy(value)),
1273 )
1274 })
1275 .collect(),
1276 metadata: Vec::new(),
1277 node_links: Vec::new(),
1278 vector_links: Vec::new(),
1279 }
1280}
1281
1282fn bulk_insert_chunk_count(row_count: usize) -> usize {
1283 if row_count == 0 {
1284 0
1285 } else {
1286 ((row_count - 1) / STDIO_BULK_INSERT_CHUNK_ROWS) + 1
1287 }
1288}
1289
1290pub(crate) fn should_bulk_insert_graph(
1291 runtime: &RedDBRuntime,
1292 collection: &str,
1293 payloads: &[&json::Map<String, Value>],
1294) -> bool {
1295 let graph_shaped = payloads
1296 .iter()
1297 .all(|payload| payload.get("label").and_then(Value::as_str).is_some());
1298 if !graph_shaped {
1299 return false;
1300 }
1301
1302 matches!(
1303 runtime
1304 .db()
1305 .catalog_model_snapshot()
1306 .collections
1307 .iter()
1308 .find(|descriptor| descriptor.name == collection)
1309 .map(|descriptor| descriptor.declared_model.unwrap_or(descriptor.model)),
1310 Some(crate::catalog::CollectionModel::Graph | crate::catalog::CollectionModel::Mixed)
1311 )
1312}
1313
1314pub(crate) fn bulk_insert_graph(
1315 runtime: &RedDBRuntime,
1316 collection: &str,
1317 payloads: &[&json::Map<String, Value>],
1318) -> crate::RedDBResult<Value> {
1319 use crate::application::entity_payload::{parse_create_edge_input, parse_create_node_input};
1320 use crate::application::ports::RuntimeEntityPort;
1321
1322 let mut ids = Vec::with_capacity(payloads.len());
1323 for payload in payloads {
1324 let input_payload = normalize_flat_graph_payload(payload);
1325 let id = if payload.contains_key("from") || payload.contains_key("to") {
1326 runtime
1327 .create_edge(parse_create_edge_input(
1328 collection.to_string(),
1329 &input_payload,
1330 )?)?
1331 .id
1332 } else {
1333 runtime
1334 .create_node(parse_create_node_input(
1335 collection.to_string(),
1336 &input_payload,
1337 )?)?
1338 .id
1339 };
1340 ids.push(Value::Number(id.raw() as f64));
1341 }
1342
1343 let mut out = json::Map::new();
1344 out.insert("affected".to_string(), Value::Number(ids.len() as f64));
1345 out.insert("ids".to_string(), Value::Array(ids));
1346 Ok(Value::Object(out))
1347}
1348
1349fn normalize_flat_graph_payload(payload: &json::Map<String, Value>) -> Value {
1350 if payload.contains_key("properties") || payload.contains_key("fields") {
1351 return Value::Object(payload.clone());
1352 }
1353
1354 let is_edge = payload.contains_key("from") || payload.contains_key("to");
1355 let mut normalized = payload.clone();
1356 let mut properties = json::Map::new();
1357 for (key, value) in payload {
1358 let reserved = if is_edge {
1359 matches!(
1360 key.as_str(),
1361 "label"
1362 | "from"
1363 | "to"
1364 | "weight"
1365 | "metadata"
1366 | "properties"
1367 | "fields"
1368 | "_ttl_ms"
1369 | "_expires_at"
1370 )
1371 } else {
1372 matches!(
1373 key.as_str(),
1374 "label"
1375 | "node_type"
1376 | "metadata"
1377 | "links"
1378 | "embeddings"
1379 | "properties"
1380 | "fields"
1381 | "_ttl_ms"
1382 | "_expires_at"
1383 )
1384 };
1385 if !reserved {
1386 properties.insert(key.clone(), value.clone());
1387 }
1388 }
1389 if !properties.is_empty() {
1390 normalized.insert("properties".to_string(), Value::Object(properties));
1391 }
1392 Value::Object(normalized)
1393}
1394
1395pub(crate) fn value_to_sql_literal(v: &Value) -> String {
1396 match v {
1397 Value::Null => "NULL".to_string(),
1398 Value::Bool(b) => b.to_string(),
1399 Value::Integer(n) => n.to_string(),
1400 Value::Number(n) => {
1401 if n.fract() == 0.0 {
1402 format!("{}", *n as i64)
1403 } else {
1404 n.to_string()
1405 }
1406 }
1407 Value::String(s) => format!("'{}'", s.replace('\'', "''")),
1408 other => format!("'{}'", other.to_string_compact().replace('\'', "''")),
1409 }
1410}
1411
1412pub(crate) fn query_result_to_json(qr: &RuntimeQueryResult) -> Value {
1415 crate::presentation::query_result::tagged_summary(qr)
1416}
1417
1418pub(crate) fn insert_result_to_json(qr: &RuntimeQueryResult) -> Value {
1419 let mut envelope = json::Map::new();
1420 envelope.insert(
1421 "affected".to_string(),
1422 Value::Number(qr.affected_rows as f64),
1423 );
1424 if let Some(first) = qr.result.records.first() {
1428 if let Some(id_val) = first
1429 .iter_fields()
1430 .find(|(k, _)| {
1431 let s: &str = k;
1432 s == "rid"
1433 })
1434 .map(|(_, v)| schema_value_to_json(v))
1435 {
1436 envelope.insert("id".to_string(), id_val);
1437 }
1438 }
1439 Value::Object(envelope)
1440}
1441
1442fn record_to_json_object(record: &UnifiedRecord) -> Value {
1443 crate::presentation::query_result::tagged_record(record)
1444}
1445
1446fn schema_value_to_json(v: &SchemaValue) -> Value {
1447 crate::presentation::query_result::tagged_value(v)
1448}
1449
1450fn dispatch_method_remote(
1460 client: &AsyncMutex<RedDBClient>,
1461 tokio_rt: &tokio::runtime::Runtime,
1462 method: &str,
1463 params: &Value,
1464) -> Result<Value, (&'static str, String)> {
1465 match method {
1466 "version" => Ok(Value::Object(
1467 [
1468 (
1469 "version".to_string(),
1470 Value::String(env!("CARGO_PKG_VERSION").to_string()),
1471 ),
1472 (
1473 "protocol".to_string(),
1474 Value::String(PROTOCOL_VERSION.to_string()),
1475 ),
1476 ]
1477 .into_iter()
1478 .collect(),
1479 )),
1480
1481 "health" => {
1482 let result = tokio_rt.block_on(async {
1483 let mut guard = client.lock().await;
1484 guard.health_status().await
1485 });
1486 match result {
1487 Ok(status) => Ok(Value::Object(
1488 [
1489 ("ok".to_string(), Value::Bool(status.healthy)),
1490 ("state".to_string(), Value::String(status.state)),
1491 (
1492 "checked_at_unix_ms".to_string(),
1493 Value::Number(status.checked_at_unix_ms as f64),
1494 ),
1495 (
1496 "version".to_string(),
1497 Value::String(env!("CARGO_PKG_VERSION").to_string()),
1498 ),
1499 ]
1500 .into_iter()
1501 .collect(),
1502 )),
1503 Err(e) => Err((error_code::INTERNAL_ERROR, e.to_string())),
1504 }
1505 }
1506
1507 "query" => {
1508 let sql = params.get("sql").and_then(Value::as_str).ok_or((
1509 error_code::INVALID_PARAMS,
1510 "missing 'sql' string".to_string(),
1511 ))?;
1512 let json_str = tokio_rt
1513 .block_on(async {
1514 let mut guard = client.lock().await;
1515 guard.query(sql).await
1516 })
1517 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1518 let parsed = json::from_str::<Value>(&json_str)
1523 .map_err(|e| (error_code::INTERNAL_ERROR, format!("bad server JSON: {e}")))?;
1524 Ok(parsed)
1525 }
1526
1527 "insert" => {
1528 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1529 error_code::INVALID_PARAMS,
1530 "missing 'collection' string".to_string(),
1531 ))?;
1532 let payload = params.get("payload").ok_or((
1533 error_code::INVALID_PARAMS,
1534 "missing 'payload' object".to_string(),
1535 ))?;
1536 if payload.as_object().is_none() {
1537 return Err((
1538 error_code::INVALID_PARAMS,
1539 "'payload' must be a JSON object".to_string(),
1540 ));
1541 }
1542 let payload_json = payload.to_string_compact();
1543 let reply = tokio_rt
1544 .block_on(async {
1545 let mut guard = client.lock().await;
1546 guard.create_row_entity(collection, &payload_json).await
1547 })
1548 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1549 let mut out = json::Map::new();
1550 out.insert("affected".to_string(), Value::Number(1.0));
1551 out.insert("id".to_string(), Value::String(reply.id.to_string()));
1552 Ok(Value::Object(out))
1553 }
1554
1555 "bulk_insert" => {
1556 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1557 error_code::INVALID_PARAMS,
1558 "missing 'collection' string".to_string(),
1559 ))?;
1560 let payloads = params.get("payloads").and_then(Value::as_array).ok_or((
1561 error_code::INVALID_PARAMS,
1562 "missing 'payloads' array".to_string(),
1563 ))?;
1564 let mut encoded = Vec::with_capacity(payloads.len());
1565 for entry in payloads {
1566 if entry.as_object().is_none() {
1567 return Err((
1568 error_code::INVALID_PARAMS,
1569 "each payload must be a JSON object".to_string(),
1570 ));
1571 }
1572 encoded.push(entry.to_string_compact());
1573 }
1574 let status = tokio_rt
1575 .block_on(async {
1576 let mut guard = client.lock().await;
1577 guard.bulk_create_rows(collection, encoded).await
1578 })
1579 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1580 Ok(Value::Object(
1581 [
1582 ("affected".to_string(), Value::Number(status.count as f64)),
1583 (
1584 "ids".to_string(),
1585 Value::Array(
1586 status
1587 .items
1588 .into_iter()
1589 .map(|item| Value::Number(item.id as f64))
1590 .collect(),
1591 ),
1592 ),
1593 ]
1594 .into_iter()
1595 .collect(),
1596 ))
1597 }
1598
1599 "get" => {
1600 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1601 error_code::INVALID_PARAMS,
1602 "missing 'collection' string".to_string(),
1603 ))?;
1604 let id = params.get("id").and_then(Value::as_str).ok_or((
1605 error_code::INVALID_PARAMS,
1606 "missing 'id' string".to_string(),
1607 ))?;
1608 ensure_sql_collection(collection).map_err(|m| (error_code::INVALID_PARAMS, m))?;
1609 let Some(rid) = rid_from_str(id) else {
1610 return Ok(Value::Object(
1611 [("entity".to_string(), Value::Null)].into_iter().collect(),
1612 ));
1613 };
1614 let sql = format!("SELECT * FROM {collection} WHERE rid = {rid} LIMIT 1");
1615 let json_str = tokio_rt
1616 .block_on(async {
1617 let mut guard = client.lock().await;
1618 guard.query(&sql).await
1619 })
1620 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1621 let parsed = json::from_str::<Value>(&json_str)
1622 .map_err(|e| (error_code::INTERNAL_ERROR, format!("bad server JSON: {e}")))?;
1623 let entity = parsed
1626 .get("rows")
1627 .and_then(Value::as_array)
1628 .and_then(|rows| rows.first().cloned())
1629 .unwrap_or(Value::Null);
1630 Ok(Value::Object(
1631 [("entity".to_string(), entity)].into_iter().collect(),
1632 ))
1633 }
1634
1635 "delete" => {
1636 let collection = params.get("collection").and_then(Value::as_str).ok_or((
1637 error_code::INVALID_PARAMS,
1638 "missing 'collection' string".to_string(),
1639 ))?;
1640 let id = params.get("id").and_then(Value::as_str).ok_or((
1641 error_code::INVALID_PARAMS,
1642 "missing 'id' string".to_string(),
1643 ))?;
1644 let id = id.parse::<u64>().map_err(|_| {
1645 (
1646 error_code::INVALID_PARAMS,
1647 "id must be a numeric string".to_string(),
1648 )
1649 })?;
1650 let _reply = tokio_rt
1651 .block_on(async {
1652 let mut guard = client.lock().await;
1653 guard.delete_entity(collection, id).await
1654 })
1655 .map_err(|e| (error_code::QUERY_ERROR, e.to_string()))?;
1656 Ok(Value::Object(
1657 [("affected".to_string(), Value::Number(1.0))]
1658 .into_iter()
1659 .collect(),
1660 ))
1661 }
1662
1663 "close" => Ok(Value::Null),
1664
1665 other => Err((
1666 error_code::INVALID_REQUEST,
1667 format!("unknown method: {other}"),
1668 )),
1669 }
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use crate::json::json;
1676 use crate::presentation::query_result::single_key_object;
1677 use proptest::prelude::*;
1678
1679 fn make_runtime() -> RedDBRuntime {
1680 RedDBRuntime::in_memory().expect("in-memory runtime")
1681 }
1682
1683 fn create_graph_collection(rt: &RedDBRuntime, name: &str) {
1684 let db = rt.db();
1685 db.store()
1686 .create_collection(name)
1687 .expect("create collection");
1688 let now = std::time::SystemTime::now()
1689 .duration_since(std::time::UNIX_EPOCH)
1690 .unwrap_or_default()
1691 .as_millis();
1692 db.save_collection_contract(crate::physical::CollectionContract {
1693 graph_contract: None,
1694 name: name.to_string(),
1695 description: None,
1696 description_generated: false,
1697 declared_model: crate::catalog::CollectionModel::Graph,
1698 schema_mode: crate::catalog::SchemaMode::Dynamic,
1699 origin: crate::physical::ContractOrigin::Explicit,
1700 version: 1,
1701 created_at_unix_ms: now,
1702 updated_at_unix_ms: now,
1703 default_ttl_ms: None,
1704 vector_dimension: None,
1705 vector_metric: None,
1706 context_index_fields: Vec::new(),
1707 declared_columns: Vec::new(),
1708 table_def: None,
1709 timestamps_enabled: false,
1710 context_index_enabled: false,
1711 metrics_raw_retention_ms: None,
1712 metrics_rollup_policies: Vec::new(),
1713 metrics_tenant_identity: None,
1714 metrics_namespace: None,
1715 append_only: false,
1716 subscriptions: Vec::new(),
1717 analytics_config: Vec::new(),
1718 session_key: None,
1719 session_gap_ms: None,
1720 retention_duration_ms: None,
1721 analytical_storage: None,
1722
1723 ai_policy: None,
1724 })
1725 .expect("save graph contract");
1726 }
1727
1728 fn handle(rt: &RedDBRuntime, line: &str) -> String {
1729 let mut session = Session::new();
1730 handle_line(&Backend::Local(rt), &mut session, line)
1731 }
1732
1733 fn query_request(id: u64, sql: &str) -> String {
1734 let mut params = json::Map::new();
1735 params.insert("sql".to_string(), Value::String(sql.to_string()));
1736
1737 let mut request = json::Map::new();
1738 request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1739 request.insert("id".to_string(), Value::Number(id as f64));
1740 request.insert("method".to_string(), Value::String("query".to_string()));
1741 request.insert("params".to_string(), Value::Object(params));
1742 Value::Object(request).to_string_compact()
1743 }
1744
1745 fn query_request_with_params(id: u64, sql: &str, binds: Vec<Value>) -> String {
1746 let mut params = json::Map::new();
1747 params.insert("sql".to_string(), Value::String(sql.to_string()));
1748 params.insert("params".to_string(), Value::Array(binds));
1749
1750 let mut request = json::Map::new();
1751 request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1752 request.insert("id".to_string(), Value::Number(id as f64));
1753 request.insert("method".to_string(), Value::String("query".to_string()));
1754 request.insert("params".to_string(), Value::Object(params));
1755 Value::Object(request).to_string_compact()
1756 }
1757
1758 fn with_session<F>(rt: &RedDBRuntime, f: F)
1761 where
1762 F: FnOnce(&dyn Fn(&str) -> String, &RedDBRuntime),
1763 {
1764 let session = std::cell::RefCell::new(Session::new());
1765 let call = |line: &str| -> String {
1766 let mut s = session.borrow_mut();
1767 handle_line(&Backend::Local(rt), &mut s, line)
1768 };
1769 f(&call, rt);
1770 }
1771
1772 fn result_rows(response: &str) -> Vec<Value> {
1773 json::from_str::<Value>(response)
1774 .expect("json response")
1775 .get("result")
1776 .and_then(|result| result.get("rows"))
1777 .and_then(Value::as_array)
1778 .map(|rows| rows.to_vec())
1779 .unwrap_or_default()
1780 }
1781
1782 fn result_name_kind(response: &str) -> Vec<(String, String)> {
1783 result_rows(response)
1784 .into_iter()
1785 .map(|row| {
1786 let object = row.as_object().expect("row object");
1787 let name = object
1788 .get("name")
1789 .and_then(Value::as_str)
1790 .expect("row name")
1791 .to_string();
1792 let kind = object
1793 .get("kind")
1794 .and_then(Value::as_str)
1795 .expect("row kind")
1796 .to_string();
1797 (name, kind)
1798 })
1799 .collect()
1800 }
1801
1802 fn json_scalar_param() -> impl Strategy<Value = Value> {
1803 prop_oneof![
1804 Just(Value::Null),
1805 any::<bool>().prop_map(Value::Bool),
1806 (-1000_i64..1000_i64).prop_map(|n| Value::Number(n as f64)),
1807 "[a-z']{0,8}".prop_map(Value::String),
1808 ]
1809 }
1810
1811 fn sql_literal_for_json(value: &Value) -> String {
1812 match value {
1813 Value::Null => "NULL".to_string(),
1814 Value::Bool(true) => "TRUE".to_string(),
1815 Value::Bool(false) => "FALSE".to_string(),
1816 Value::Number(n) => format!("{n:.0}"),
1817 Value::String(s) => format!("'{}'", s.replace('\'', "''")),
1818 _ => panic!("unsupported scalar param: {value:?}"),
1819 }
1820 }
1821
1822 #[test]
1823 fn version_method_returns_version_and_protocol() {
1824 let rt = make_runtime();
1825 let line = r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#;
1826 let resp = handle(&rt, line);
1827 assert!(resp.contains("\"id\":1"));
1828 assert!(resp.contains("\"protocol\":\"1.0\""));
1829 assert!(resp.contains("\"version\""));
1830 }
1831
1832 #[test]
1833 fn health_method_returns_ok_true() {
1834 let rt = make_runtime();
1835 let resp = handle(
1836 &rt,
1837 r#"{"jsonrpc":"2.0","id":"abc","method":"health","params":{}}"#,
1838 );
1839 assert!(resp.contains("\"ok\":true"));
1840 assert!(resp.contains("\"id\":\"abc\""));
1841 }
1842
1843 #[test]
1844 fn parse_error_for_invalid_json() {
1845 let rt = make_runtime();
1846 let resp = handle(&rt, "not json {");
1847 assert!(resp.contains("\"code\":\"PARSE_ERROR\""));
1848 assert!(resp.contains("\"id\":null"));
1849 }
1850
1851 #[test]
1852 fn invalid_request_when_method_missing() {
1853 let rt = make_runtime();
1854 let resp = handle(&rt, r#"{"jsonrpc":"2.0","id":1,"params":{}}"#);
1855 assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
1856 }
1857
1858 #[test]
1859 fn unknown_method_is_invalid_request() {
1860 let rt = make_runtime();
1861 let resp = handle(
1862 &rt,
1863 r#"{"jsonrpc":"2.0","id":1,"method":"frobnicate","params":{}}"#,
1864 );
1865 assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
1866 assert!(resp.contains("frobnicate"));
1867 }
1868
1869 #[test]
1870 fn invalid_params_when_query_sql_missing() {
1871 let rt = make_runtime();
1872 let resp = handle(
1873 &rt,
1874 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{}}"#,
1875 );
1876 assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
1877 }
1878
1879 #[test]
1880 fn query_params_reject_superseded_exact_number_envelope() {
1881 let rt = make_runtime();
1882 let resp = handle(
1883 &rt,
1884 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT $1","params":[{"$number":"1"}]}}"#,
1885 );
1886 assert!(resp.contains("\"code\":\"INVALID_PARAMS\""), "got: {resp}");
1887 assert!(
1888 resp.contains("superseded exact-number envelope"),
1889 "got: {resp}"
1890 );
1891 }
1892
1893 #[test]
1894 fn embedded_session_serves_the_stdio_protocol_without_a_process() {
1895 let rt = std::sync::Arc::new(make_runtime());
1896 let mut a = EmbeddedRpcSession::new(rt.clone());
1897 let mut b = EmbeddedRpcSession::new(rt);
1898
1899 let (resp, closed) = a.handle(r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#);
1900 assert!(resp.contains("\"result\""), "got: {resp}");
1901 assert!(!closed);
1902
1903 let (resp, _) = a.handle(
1904 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"CREATE TABLE emb (id INTEGER, name TEXT)"}}"#,
1905 );
1906 assert!(resp.contains("\"result\""), "got: {resp}");
1907 let (resp, _) = a.handle(
1908 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO emb (id, name) VALUES (1, 'x')"}}"#,
1909 );
1910 assert!(resp.contains("\"result\""), "got: {resp}");
1911
1912 let (resp, _) = b.handle(
1914 r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT id, name FROM emb"}}"#,
1915 );
1916 assert!(resp.contains("\"x\""), "got: {resp}");
1917
1918 let (resp, closed) = a.handle(r#"{"jsonrpc":"2.0","id":5,"method":"close","params":{}}"#);
1919 assert!(closed, "got: {resp}");
1920 }
1921
1922 #[test]
1923 fn embedded_session_transactions_are_scoped_to_the_session() {
1924 let rt = std::sync::Arc::new(make_runtime());
1925 let mut a = EmbeddedRpcSession::new(rt.clone());
1926 let mut b = EmbeddedRpcSession::new(rt);
1927 let _ = a.handle(
1928 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE emb_tx (id INTEGER)"}}"#,
1929 );
1930
1931 let (begin, _) = a.handle(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":{}}"#);
1932 assert!(begin.contains("\"result\""), "got: {begin}");
1933 let (begin_b, _) = b.handle(r#"{"jsonrpc":"2.0","id":3,"method":"tx.begin","params":{}}"#);
1935 assert!(begin_b.contains("\"result\""), "got: {begin_b}");
1936 let (again, _) = a.handle(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":{}}"#);
1937 assert!(again.contains("TX_ALREADY_OPEN"), "got: {again}");
1938 }
1939
1940 #[test]
1941 fn close_method_marks_response_for_shutdown() {
1942 let rt = make_runtime();
1943 let resp = handle(
1944 &rt,
1945 r#"{"jsonrpc":"2.0","id":1,"method":"close","params":{}}"#,
1946 );
1947 assert!(resp.contains("\"__close__\":true"));
1948 }
1949
1950 #[test]
1951 fn query_with_int_text_params_round_trips() {
1952 let rt = make_runtime();
1953 let _ = handle(
1954 &rt,
1955 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE p (id INTEGER, name TEXT)"}}"#,
1956 );
1957 let _ = handle(
1958 &rt,
1959 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (1, 'Alice')"}}"#,
1960 );
1961 let _ = handle(
1962 &rt,
1963 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (2, 'Bob')"}}"#,
1964 );
1965 let resp = handle(
1966 &rt,
1967 r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT * FROM p WHERE id = $1 AND name = $2","params":[1,"Alice"]}}"#,
1968 );
1969 assert!(resp.contains("\"Alice\""), "got: {resp}");
1970 assert!(!resp.contains("\"Bob\""), "got: {resp}");
1971 }
1972
1973 #[test]
1974 fn query_with_question_params_covers_select_insert_update_delete() {
1975 let rt = make_runtime();
1976 let create = handle(
1977 &rt,
1978 &query_request(1, "CREATE TABLE qp (id INTEGER, name TEXT)"),
1979 );
1980 assert!(!create.contains("\"error\""), "got: {create}");
1981
1982 let inserted = handle(
1983 &rt,
1984 &query_request_with_params(
1985 2,
1986 "INSERT INTO qp (id, name) VALUES (?, ?)",
1987 vec![json!(1), json!("O'Reilly")],
1988 ),
1989 );
1990 assert!(inserted.contains("\"affected\":1"), "got: {inserted}");
1991
1992 let selected = handle(
1993 &rt,
1994 &query_request_with_params(3, "SELECT name FROM qp WHERE id = ?", vec![json!(1)]),
1995 );
1996 let rows = result_rows(&selected);
1997 assert_eq!(rows.len(), 1, "got: {selected}");
1998 assert_eq!(
1999 rows[0].get("name").and_then(Value::as_str),
2000 Some("O'Reilly")
2001 );
2002
2003 let selected_numbered = handle(
2004 &rt,
2005 &query_request_with_params(
2006 4,
2007 "SELECT name FROM qp WHERE name = ?1 AND id = ?2",
2008 vec![json!("O'Reilly"), json!(1)],
2009 ),
2010 );
2011 assert_eq!(
2012 result_rows(&selected_numbered).len(),
2013 1,
2014 "got: {selected_numbered}"
2015 );
2016
2017 let updated = handle(
2018 &rt,
2019 &query_request_with_params(
2020 5,
2021 "UPDATE qp SET name = ? WHERE id = ?",
2022 vec![json!("Alice"), json!(1)],
2023 ),
2024 );
2025 assert!(updated.contains("\"affected\":1"), "got: {updated}");
2026
2027 let deleted = handle(
2028 &rt,
2029 &query_request_with_params(6, "DELETE FROM qp WHERE name = ?", vec![json!("Alice")]),
2030 );
2031 assert!(deleted.contains("\"affected\":1"), "got: {deleted}");
2032
2033 let remaining = handle(&rt, &query_request(7, "SELECT * FROM qp"));
2034 assert!(result_rows(&remaining).is_empty(), "got: {remaining}");
2035 }
2036
2037 #[test]
2038 fn query_with_params_insert_and_search_round_trip() {
2039 let rt = make_runtime();
2040 let insert = handle(
2041 &rt,
2042 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"INSERT INTO bun_embeddings VECTOR (dense, content) VALUES ($1, $2)","params":[[1.0,0.0],"bun vector"]}}"#,
2043 );
2044 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2045
2046 let search = handle(
2047 &rt,
2048 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SEARCH SIMILAR $1 COLLECTION bun_embeddings LIMIT 1","params":[[1.0,0.0]]}}"#,
2049 );
2050 assert!(search.contains("\"rows\""), "got: {search}");
2051 assert!(search.contains("\"score\":1"), "got: {search}");
2052 assert!(!search.contains("\"error\""), "got: {search}");
2053 }
2054
2055 #[test]
2056 fn query_with_question_vector_param_round_trips() {
2057 let rt = make_runtime();
2058 let insert = handle(
2059 &rt,
2060 &query_request_with_params(
2061 1,
2062 "INSERT INTO question_embeddings VECTOR (dense, content) VALUES (?, ?)",
2063 vec![json!([1.0, 0.0]), json!("question vector")],
2064 ),
2065 );
2066 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2067
2068 let search = handle(
2069 &rt,
2070 &query_request_with_params(
2071 2,
2072 "SEARCH SIMILAR ? COLLECTION question_embeddings LIMIT 1",
2073 vec![json!([1.0, 0.0])],
2074 ),
2075 );
2076 assert!(search.contains("\"rows\""), "got: {search}");
2077 assert!(search.contains("\"score\":1"), "got: {search}");
2078 assert!(!search.contains("\"error\""), "got: {search}");
2079 }
2080
2081 #[test]
2082 fn query_with_typed_json_rpc_params_round_trips() {
2083 let rt = make_runtime();
2084 let create = handle(
2085 &rt,
2086 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE value_params (ok BOOLEAN, score FLOAT, payload BLOB, body JSON, seen_at TIMESTAMP, ident UUID)"}}"#,
2087 );
2088 assert!(!create.contains("\"error\""), "got: {create}");
2089
2090 let insert = handle(
2091 &rt,
2092 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO value_params (ok, score, payload, body, seen_at, ident) VALUES ($1, $2, $3, $4, $5, $6)","params":[true,{"$float":"NaN"},{"$bytes":"3q2+7w=="},{"z":[1,{"a":true}],"a":null},{"$ts":"1700000000123456789"},{"$uuid":"00112233-4455-6677-8899-aabbccddeeff"}]}}"#,
2093 );
2094 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2095
2096 let selected = handle(
2097 &rt,
2098 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"SELECT * FROM value_params"}}"#,
2099 );
2100 assert!(selected.contains("\"ok\":true"), "got: {selected}");
2101 assert!(selected.contains("\"$float\":\"NaN\""), "got: {selected}");
2102 assert!(
2103 selected.contains("\"$bytes\":\"3q2+7w==\""),
2104 "got: {selected}"
2105 );
2106 assert!(
2107 selected.contains("\"body\":{\"a\":null,\"z\":[1,{\"a\":true}]}"),
2108 "got: {selected}"
2109 );
2110 assert!(
2111 selected.contains("\"$ts\":\"1700000000123456789\""),
2112 "got: {selected}"
2113 );
2114 assert!(
2115 selected.contains("\"$uuid\":\"00112233-4455-6677-8899-aabbccddeeff\""),
2116 "got: {selected}"
2117 );
2118 }
2119
2120 #[test]
2121 fn select_timeseries_tags_decodes_json_payload() {
2122 let rt = make_runtime();
2123 let create = handle(&rt, &query_request(1, "CREATE TIMESERIES ts1"));
2124 assert!(!create.contains("\"error\""), "got: {create}");
2125
2126 let insert = handle(
2127 &rt,
2128 &query_request(
2129 2,
2130 r#"INSERT INTO ts1 (metric, value, tags, timestamp) VALUES ('cpu', 85, '{"host":"a"}', 1000)"#,
2131 ),
2132 );
2133 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2134
2135 let selected = handle(&rt, &query_request(3, "SELECT tags FROM ts1"));
2136 assert!(!selected.contains("<json"), "got: {selected}");
2137 let response = json::from_str::<Value>(&selected).expect("response json");
2138 let tags = response
2139 .get("result")
2140 .and_then(|result| result.get("rows"))
2141 .and_then(Value::as_array)
2142 .and_then(|rows| rows.first())
2143 .and_then(|row| row.get("tags"))
2144 .expect("tags field");
2145 assert_eq!(tags, &json!({"host": "a"}));
2146 }
2147
2148 #[test]
2149 fn select_table_json_column_round_trips_after_single_parse() {
2150 let rt = make_runtime();
2151 let create = handle(&rt, &query_request(1, "CREATE TABLE docs (payload JSON)"));
2152 assert!(!create.contains("\"error\""), "got: {create}");
2153
2154 let original = r#"{"nested":{"items":[1,true,"x"],"object":{"k":"v"}}}"#;
2155 let insert_sql = format!("INSERT INTO docs (payload) VALUES ({original})");
2156 let insert = handle(&rt, &query_request(2, &insert_sql));
2157 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2158
2159 let selected = handle(&rt, &query_request(3, "SELECT payload FROM docs"));
2160 assert!(!selected.contains("<json"), "got: {selected}");
2161 let response = json::from_str::<Value>(&selected).expect("response json");
2162 let payload = response
2163 .get("result")
2164 .and_then(|result| result.get("rows"))
2165 .and_then(Value::as_array)
2166 .and_then(|rows| rows.first())
2167 .and_then(|row| row.get("payload"))
2168 .expect("payload field");
2169 let expected = json::from_str::<Value>(original).expect("expected json");
2170 assert_eq!(payload, &expected);
2171
2172 let payload_text = payload.to_string_compact();
2173 assert_eq!(
2174 json::from_str::<Value>(&payload_text).expect("single parse"),
2175 expected
2176 );
2177 }
2178
2179 #[test]
2180 fn select_json_corruption_falls_back_to_code_and_hex() {
2181 use crate::storage::query::unified::UnifiedResult;
2182
2183 let mut result = UnifiedResult::with_columns(vec!["payload".into()]);
2184 let mut record = UnifiedRecord::new();
2185 record.set("payload", SchemaValue::Json(b"{not json".to_vec()));
2186 result.push(record);
2187
2188 let json = query_result_to_json(&RuntimeQueryResult {
2189 query: "SELECT payload FROM docs".to_string(),
2190 mode: reddb_rql::modes::QueryMode::Sql,
2191 statement: "select",
2192 engine: "runtime-table",
2193 result,
2194 affected_rows: 0,
2195 statement_type: "select",
2196 bookmark: None,
2197 notice: None,
2198 });
2199
2200 let payload = json
2201 .get("rows")
2202 .and_then(Value::as_array)
2203 .and_then(|rows| rows.first())
2204 .and_then(|row| row.get("payload"))
2205 .expect("payload field");
2206 assert_eq!(
2207 payload.get("code").and_then(Value::as_str),
2208 Some("INVALID_JSON")
2209 );
2210 assert_eq!(
2211 payload.get("hex").and_then(Value::as_str),
2212 Some("7b6e6f74206a736f6e")
2213 );
2214 }
2215
2216 #[test]
2217 fn query_result_json_preserves_exact_numbers() {
2218 use crate::storage::query::unified::UnifiedResult;
2219
2220 let mut result =
2221 UnifiedResult::with_columns(vec!["big".into(), "unsigned".into(), "decimal".into()]);
2222 let mut record = UnifiedRecord::new();
2223 record.set("big", SchemaValue::Integer(9_007_199_254_740_993));
2224 record.set(
2225 "unsigned",
2226 SchemaValue::UnsignedInteger(i64::MAX as u64 + 1),
2227 );
2228 record.set(
2229 "decimal",
2230 SchemaValue::DecimalText("3.14159265358979323846".to_string()),
2231 );
2232 result.push(record);
2233
2234 let json = query_result_to_json(&RuntimeQueryResult {
2235 query: "SELECT exact".to_string(),
2236 mode: reddb_rql::modes::QueryMode::Sql,
2237 statement: "select",
2238 engine: "runtime-table",
2239 result,
2240 affected_rows: 0,
2241 statement_type: "select",
2242 bookmark: None,
2243 notice: None,
2244 });
2245
2246 let row = json
2247 .get("rows")
2248 .and_then(Value::as_array)
2249 .and_then(|rows| rows.first())
2250 .expect("row");
2251 assert_eq!(
2252 row.get("big"),
2253 Some(&single_key_object(
2254 "$int",
2255 Value::String("9007199254740993".to_string())
2256 ))
2257 );
2258 assert_eq!(
2259 row.get("unsigned"),
2260 Some(&single_key_object(
2261 "$uint",
2262 Value::String("9223372036854775808".to_string())
2263 ))
2264 );
2265 assert_eq!(
2266 row.get("decimal"),
2267 Some(&single_key_object(
2268 "$decimal",
2269 Value::String("3.14159265358979323846".to_string())
2270 ))
2271 );
2272 }
2273
2274 #[test]
2275 fn query_with_params_arity_mismatch_rejected() {
2276 let rt = make_runtime();
2277 let _ = handle(
2278 &rt,
2279 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pa (id INTEGER)"}}"#,
2280 );
2281 let resp = handle(
2282 &rt,
2283 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pa WHERE id = $1","params":[1,2]}}"#,
2284 );
2285 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2286 }
2287
2288 #[test]
2289 fn query_with_question_params_arity_mismatch_rejected() {
2290 let rt = make_runtime();
2291 let _ = handle(&rt, &query_request(1, "CREATE TABLE qpa (id INTEGER)"));
2292 let resp = handle(
2293 &rt,
2294 &query_request_with_params(
2295 2,
2296 "SELECT * FROM qpa WHERE id = ?",
2297 vec![json!(1), json!(2)],
2298 ),
2299 );
2300 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2301 assert!(resp.contains("SQL expects 1, got 2"), "got: {resp}");
2302 }
2303
2304 #[test]
2305 fn query_with_params_gap_rejected() {
2306 let rt = make_runtime();
2307 let _ = handle(
2308 &rt,
2309 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pg (a INTEGER, b INTEGER)"}}"#,
2310 );
2311 let resp = handle(
2312 &rt,
2313 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pg WHERE a = $1 AND b = $3","params":[1,2,3]}}"#,
2314 );
2315 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2316 }
2317
2318 #[test]
2319 fn query_with_question_numbered_gap_rejected() {
2320 let rt = make_runtime();
2321 let _ = handle(&rt, &query_request(1, "CREATE TABLE qpg (id INTEGER)"));
2322 let resp = handle(
2323 &rt,
2324 &query_request_with_params(
2325 2,
2326 "SELECT * FROM qpg WHERE id = ?2",
2327 vec![json!(1), json!(2)],
2328 ),
2329 );
2330 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2331 assert!(resp.contains("parameter $`1` is missing"), "got: {resp}");
2332 }
2333
2334 #[test]
2335 fn query_with_question_params_type_mismatch_names_slot() {
2336 let rt = make_runtime();
2337 let _ = handle(&rt, &query_request(1, "CREATE TABLE qpt (id INTEGER)"));
2338 let resp = handle(
2339 &rt,
2340 &query_request_with_params(
2341 2,
2342 "INSERT INTO qpt (id) VALUES (?)",
2343 vec![json!("not-an-integer")],
2344 ),
2345 );
2346 assert!(resp.contains("\"QUERY_ERROR\""), "got: {resp}");
2347 assert!(resp.contains("id"), "got: {resp}");
2348 assert!(resp.contains("integer"), "got: {resp}");
2349 }
2350
2351 #[test]
2352 fn query_select_one_returns_rows() {
2353 let rt = make_runtime();
2354 let resp = handle(
2355 &rt,
2356 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT 1 AS one"}}"#,
2357 );
2358 assert!(resp.contains("\"result\""));
2359 assert!(!resp.contains("\"error\""));
2360 }
2361
2362 #[test]
2363 fn ask_query_result_uses_canonical_envelope() {
2364 use crate::storage::query::unified::UnifiedResult;
2365
2366 let mut result = UnifiedResult::with_columns(vec![
2367 "answer".into(),
2368 "provider".into(),
2369 "model".into(),
2370 "prompt_tokens".into(),
2371 "completion_tokens".into(),
2372 "sources_count".into(),
2373 "sources_flat".into(),
2374 "citations".into(),
2375 "validation".into(),
2376 ]);
2377 let mut record = UnifiedRecord::new();
2378 record.set("answer", SchemaValue::text("Deploy failed [^1]."));
2379 record.set("provider", SchemaValue::text("openai"));
2380 record.set("model", SchemaValue::text("gpt-4o-mini"));
2381 record.set("prompt_tokens", SchemaValue::Integer(11));
2382 record.set("completion_tokens", SchemaValue::Integer(7));
2383 record.set(
2384 "sources_flat",
2385 SchemaValue::Json(
2386 br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
2387 ),
2388 );
2389 record.set(
2390 "citations",
2391 SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
2392 );
2393 record.set(
2394 "validation",
2395 SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
2396 );
2397 result.push(record);
2398
2399 let json = query_result_to_json(&RuntimeQueryResult {
2400 query: "ASK 'why did deploy fail?'".to_string(),
2401 mode: reddb_rql::modes::QueryMode::Sql,
2402 statement: "ask",
2403 engine: "runtime-ai",
2404 result,
2405 affected_rows: 0,
2406 statement_type: "select",
2407 bookmark: None,
2408 notice: None,
2409 });
2410
2411 assert_eq!(
2412 json.get("answer").and_then(Value::as_str),
2413 Some("Deploy failed [^1].")
2414 );
2415 assert_eq!(json.get("cache_hit").and_then(Value::as_bool), Some(false));
2416 assert_eq!(json.get("cost_usd").and_then(Value::as_f64), Some(0.0));
2417 assert_eq!(json.get("mode").and_then(Value::as_str), Some("strict"));
2418 assert_eq!(json.get("retry_count").and_then(Value::as_u64), Some(0));
2419 assert!(
2420 json.get("rows").is_none(),
2421 "ASK envelope must not be row-wrapped: {json}"
2422 );
2423 assert!(
2424 json.get("sources_flat")
2425 .and_then(Value::as_array)
2426 .is_some_and(|sources| sources.len() == 1
2427 && sources[0].get("payload").and_then(Value::as_str).is_some()),
2428 "sources_flat must be a parsed array: {json}"
2429 );
2430 assert!(
2431 json.get("citations")
2432 .and_then(Value::as_array)
2433 .is_some_and(|citations| citations.len() == 1),
2434 "citations must be a parsed array: {json}"
2435 );
2436 assert_eq!(
2437 json.get("validation")
2438 .and_then(|v| v.get("ok"))
2439 .and_then(Value::as_bool),
2440 Some(true)
2441 );
2442 }
2443
2444 #[test]
2449 fn tx_begin_returns_tx_id_and_isolation() {
2450 let rt = make_runtime();
2451 with_session(&rt, |call, _| {
2452 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2453 assert!(resp.contains("\"tx_id\":1"));
2454 assert!(resp.contains("\"isolation\":\"read_committed_deferred\""));
2455 assert!(!resp.contains("\"error\""));
2456 });
2457 }
2458
2459 #[test]
2460 fn tx_begin_twice_returns_already_open() {
2461 let rt = make_runtime();
2462 with_session(&rt, |call, _| {
2463 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2464 let resp = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2465 assert!(resp.contains("\"code\":\"TX_ALREADY_OPEN\""));
2466 });
2467 }
2468
2469 #[test]
2470 fn tx_commit_without_begin_returns_no_tx_open() {
2471 let rt = make_runtime();
2472 with_session(&rt, |call, _| {
2473 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.commit","params":null}"#);
2474 assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2475 });
2476 }
2477
2478 #[test]
2479 fn tx_rollback_without_begin_returns_no_tx_open() {
2480 let rt = make_runtime();
2481 with_session(&rt, |call, _| {
2482 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.rollback","params":null}"#);
2483 assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2484 });
2485 }
2486
2487 #[test]
2488 fn insert_inside_tx_returns_pending_envelope() {
2489 let rt = make_runtime();
2490 let _ = handle(
2492 &rt,
2493 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE users (name TEXT)"}}"#,
2494 );
2495 with_session(&rt, |call, _| {
2496 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2497 let resp = call(
2498 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"users","payload":{"name":"alice"}}}"#,
2499 );
2500 assert!(resp.contains("\"pending\":true"));
2501 assert!(resp.contains("\"tx_id\":1"));
2502 assert!(resp.contains("\"affected\":0"));
2503 });
2504 }
2505
2506 #[test]
2507 fn begin_insert_rollback_does_not_persist() {
2508 let rt = make_runtime();
2509 let _ = handle(
2510 &rt,
2511 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u (name TEXT)"}}"#,
2512 );
2513 with_session(&rt, |call, _| {
2514 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2515 let _ = call(
2516 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u","payload":{"name":"ghost"}}}"#,
2517 );
2518 let rollback = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2519 assert!(rollback.contains("\"ops_discarded\":1"));
2520 assert!(rollback.contains("\"tx_id\":1"));
2521 });
2522 let resp = handle(
2524 &rt,
2525 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u"}}"#,
2526 );
2527 assert!(!resp.contains("\"ghost\""));
2528 }
2529
2530 #[test]
2531 fn begin_insert_commit_persists() {
2532 let rt = make_runtime();
2533 let _ = handle(
2534 &rt,
2535 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u2 (name TEXT)"}}"#,
2536 );
2537 with_session(&rt, |call, _| {
2538 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2539 let _ = call(
2540 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u2","payload":{"name":"alice"}}}"#,
2541 );
2542 let _ = call(
2543 r#"{"jsonrpc":"2.0","id":3,"method":"insert","params":{"collection":"u2","payload":{"name":"bob"}}}"#,
2544 );
2545 let commit = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.commit","params":null}"#);
2546 assert!(commit.contains("\"ops_replayed\":2"));
2547 assert!(!commit.contains("\"error\""));
2548 });
2549 let resp = handle(
2550 &rt,
2551 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u2"}}"#,
2552 );
2553 assert!(resp.contains("\"alice\""));
2554 assert!(resp.contains("\"bob\""));
2555 }
2556
2557 #[test]
2558 fn bulk_insert_inside_tx_buffers_everything() {
2559 let rt = make_runtime();
2560 let _ = handle(
2561 &rt,
2562 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u3 (name TEXT)"}}"#,
2563 );
2564 with_session(&rt, |call, _| {
2565 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2566 let resp = call(
2567 r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"u3","payloads":[{"name":"a"},{"name":"b"},{"name":"c"}]}}"#,
2568 );
2569 assert!(resp.contains("\"buffered\":3"));
2570 assert!(resp.contains("\"pending\":true"));
2571 assert!(resp.contains("\"affected\":0"));
2572
2573 let commit = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2574 assert!(commit.contains("\"ops_replayed\":3"));
2575 });
2576 }
2577
2578 #[test]
2579 fn bulk_insert_chunks_at_internal_500_row_limit() {
2580 assert_eq!(bulk_insert_chunk_count(0), 0);
2581 assert_eq!(bulk_insert_chunk_count(1), 1);
2582 assert_eq!(bulk_insert_chunk_count(500), 1);
2583 assert_eq!(bulk_insert_chunk_count(501), 2);
2584 assert_eq!(bulk_insert_chunk_count(1000), 2);
2585 assert_eq!(bulk_insert_chunk_count(1001), 3);
2586 }
2587
2588 proptest! {
2589 #![proptest_config(ProptestConfig {
2590 cases: 12,
2591 ..ProptestConfig::default()
2592 })]
2593
2594 #[test]
2595 fn bulk_insert_matches_sequential_insert_state(
2596 names in proptest::collection::vec("[a-z]{1,8}", 1usize..20)
2597 ) {
2598 let rt = make_runtime();
2599 let payloads = names
2600 .iter()
2601 .map(|name| format!(r#"{{"name":"{name}","kind":"bulk"}}"#))
2602 .collect::<Vec<_>>();
2603 let payload_array = payloads.join(",");
2604
2605 let bulk = handle(
2606 &rt,
2607 &format!(
2608 r#"{{"jsonrpc":"2.0","id":1,"method":"bulk_insert","params":{{"collection":"bulk_prop","payloads":[{payload_array}]}}}}"#
2609 ),
2610 );
2611 let bulk_result = json::from_str::<Value>(&bulk).expect("bulk json");
2612 let bulk_ids = bulk_result
2613 .get("result")
2614 .and_then(|result| result.get("ids"))
2615 .and_then(Value::as_array)
2616 .expect("bulk ids");
2617 prop_assert_eq!(bulk_ids.len(), names.len());
2618
2619 for (index, payload) in payloads.iter().enumerate() {
2620 let insert = handle(
2621 &rt,
2622 &format!(
2623 r#"{{"jsonrpc":"2.0","id":{},"method":"insert","params":{{"collection":"seq_prop","payload":{payload}}}}}"#,
2624 index + 10
2625 ),
2626 );
2627 let insert_result = json::from_str::<Value>(&insert).expect("insert json");
2628 prop_assert!(
2629 insert_result
2630 .get("result")
2631 .and_then(|result| result.get("id"))
2632 .is_some(),
2633 "insert response missing id: {insert}"
2634 );
2635 }
2636
2637 let bulk_rows = result_name_kind(&handle(
2638 &rt,
2639 r#"{"jsonrpc":"2.0","id":99,"method":"query","params":{"sql":"SELECT name, kind FROM bulk_prop ORDER BY rid"}}"#,
2640 ));
2641 let seq_rows = result_name_kind(&handle(
2642 &rt,
2643 r#"{"jsonrpc":"2.0","id":100,"method":"query","params":{"sql":"SELECT name, kind FROM seq_prop ORDER BY rid"}}"#,
2644 ));
2645 prop_assert_eq!(bulk_rows, seq_rows);
2646 }
2647
2648 #[test]
2649 fn question_param_select_matches_inlined_literal(value in json_scalar_param()) {
2650 let rt = make_runtime();
2651 let bound = handle(
2652 &rt,
2653 &query_request_with_params(1, "SELECT ? AS v", vec![value.clone()]),
2654 );
2655 let inline_sql = format!("SELECT {} AS v", sql_literal_for_json(&value));
2656 let inlined = handle(&rt, &query_request(2, &inline_sql));
2657 prop_assert_eq!(
2658 result_rows(&bound),
2659 result_rows(&inlined),
2660 "bound={}, inlined={}",
2661 bound,
2662 inlined
2663 );
2664 }
2665 }
2666
2667 #[test]
2668 fn bulk_insert_graph_nodes_accepts_flat_rows_and_returns_ids() {
2669 let rt = make_runtime();
2670 create_graph_collection(&rt, "social");
2671
2672 let resp = handle(
2673 &rt,
2674 r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"social","payloads":[{"label":"User","name":"alice"},{"label":"User","name":"bob"}]}}"#,
2675 );
2676 let envelope: Value = json::from_str(&resp).expect("json response");
2677 let result = envelope.get("result").expect("result");
2678 assert_eq!(result.get("affected").and_then(Value::as_u64), Some(2));
2679 assert_eq!(
2680 result
2681 .get("ids")
2682 .and_then(Value::as_array)
2683 .map(|ids| ids.len()),
2684 Some(2)
2685 );
2686
2687 let query = handle(
2688 &rt,
2689 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"MATCH (n:User) RETURN n.name"}}"#,
2690 );
2691 assert!(query.contains("\"alice\""), "got: {query}");
2692 assert!(query.contains("\"bob\""), "got: {query}");
2693 }
2694
2695 #[test]
2696 fn bulk_insert_graph_edges_accepts_flat_rows_and_returns_ids() {
2697 let rt = make_runtime();
2698 create_graph_collection(&rt, "network");
2699 let nodes = handle(
2700 &rt,
2701 r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"network","payloads":[{"label":"Host","name":"app"},{"label":"Host","name":"db"}]}}"#,
2702 );
2703 let envelope: Value = json::from_str(&nodes).expect("node response");
2704 let ids = envelope
2705 .get("result")
2706 .and_then(|r| r.get("ids"))
2707 .and_then(Value::as_array)
2708 .expect("node ids");
2709 let from = ids[0].as_u64().expect("from id");
2710 let to = ids[1].as_u64().expect("to id");
2711
2712 let resp = handle(
2713 &rt,
2714 &format!(
2715 r#"{{"jsonrpc":"2.0","id":3,"method":"bulk_insert","params":{{"collection":"network","payloads":[{{"label":"connects","from":{from},"to":{to},"weight":0.5,"role":"primary"}}]}}}}"#
2716 ),
2717 );
2718 let envelope: Value = json::from_str(&resp).expect("edge response");
2719 let result = envelope.get("result").expect("result");
2720 assert_eq!(result.get("affected").and_then(Value::as_u64), Some(1));
2721 assert_eq!(
2722 result
2723 .get("ids")
2724 .and_then(Value::as_array)
2725 .map(|ids| ids.len()),
2726 Some(1)
2727 );
2728 }
2729
2730 #[test]
2731 fn delete_inside_tx_is_buffered() {
2732 let rt = make_runtime();
2733 let _ = handle(
2735 &rt,
2736 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u4 (name TEXT)"}}"#,
2737 );
2738 let _ = handle(
2739 &rt,
2740 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO u4 (name) VALUES ('keep')"}}"#,
2741 );
2742 with_session(&rt, |call, _| {
2743 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2744 let resp = call(
2745 r#"{"jsonrpc":"2.0","id":2,"method":"delete","params":{"collection":"u4","id":"1"}}"#,
2746 );
2747 assert!(resp.contains("\"pending\":true"));
2748 let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2749 });
2750 let resp = handle(
2752 &rt,
2753 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u4"}}"#,
2754 );
2755 assert!(resp.contains("\"keep\""));
2756 }
2757
2758 #[test]
2759 fn close_with_open_tx_auto_rollbacks() {
2760 let rt = make_runtime();
2761 let _ = handle(
2762 &rt,
2763 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u5 (name TEXT)"}}"#,
2764 );
2765 with_session(&rt, |call, _| {
2766 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2767 let _ = call(
2768 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u5","payload":{"name":"ghost"}}}"#,
2769 );
2770 let close = call(r#"{"jsonrpc":"2.0","id":3,"method":"close","params":null}"#);
2771 assert!(close.contains("\"__close__\":true"));
2772 assert!(!close.contains("\"error\""));
2773 });
2774 let resp = handle(
2775 &rt,
2776 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u5"}}"#,
2777 );
2778 assert!(!resp.contains("\"ghost\""));
2779 }
2780
2781 fn seed_numbers_table(rt: &RedDBRuntime, table: &str, count: u32) {
2786 let _ = handle(
2787 rt,
2788 &format!(
2789 r#"{{"jsonrpc":"2.0","id":1,"method":"query","params":{{"sql":"CREATE TABLE {table} (n INTEGER)"}}}}"#,
2790 ),
2791 );
2792 for i in 0..count {
2793 let _ = handle(
2794 rt,
2795 &format!(
2796 r#"{{"jsonrpc":"2.0","id":2,"method":"query","params":{{"sql":"INSERT INTO {table} (n) VALUES ({i})"}}}}"#,
2797 ),
2798 );
2799 }
2800 }
2801
2802 #[test]
2803 fn cursor_open_returns_id_columns_and_total() {
2804 let rt = make_runtime();
2805 seed_numbers_table(&rt, "nums1", 3);
2806 with_session(&rt, |call, _| {
2807 let resp = call(
2808 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums1"}}"#,
2809 );
2810 assert!(resp.contains("\"cursor_id\":1"));
2811 assert!(resp.contains("\"total_rows\":3"));
2812 assert!(resp.contains("\"columns\""));
2813 assert!(!resp.contains("\"error\""));
2814 });
2815 }
2816
2817 #[test]
2818 fn cursor_next_chunks_rows_and_signals_done() {
2819 let rt = make_runtime();
2820 seed_numbers_table(&rt, "nums2", 5);
2821 with_session(&rt, |call, _| {
2822 let _ = call(
2823 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums2"}}"#,
2824 );
2825 let first = call(
2826 r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2827 );
2828 assert!(first.contains("\"done\":false"));
2829 assert!(first.contains("\"remaining\":3"));
2830
2831 let second = call(
2832 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2833 );
2834 assert!(second.contains("\"done\":false"));
2835 assert!(second.contains("\"remaining\":1"));
2836
2837 let third = call(
2838 r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2839 );
2840 assert!(third.contains("\"done\":true"));
2841 assert!(third.contains("\"remaining\":0"));
2842 });
2843 }
2844
2845 #[test]
2846 fn cursor_auto_drops_when_exhausted() {
2847 let rt = make_runtime();
2848 seed_numbers_table(&rt, "nums3", 2);
2849 with_session(&rt, |call, _| {
2850 let _ = call(
2851 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums3"}}"#,
2852 );
2853 let _ = call(
2854 r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2855 );
2856 let resp = call(
2859 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2860 );
2861 assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2862 });
2863 }
2864
2865 #[test]
2866 fn cursor_close_removes_it() {
2867 let rt = make_runtime();
2868 seed_numbers_table(&rt, "nums4", 3);
2869 with_session(&rt, |call, _| {
2870 let _ = call(
2871 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums4"}}"#,
2872 );
2873 let close =
2874 call(r#"{"jsonrpc":"2.0","id":2,"method":"query.close","params":{"cursor_id":1}}"#);
2875 assert!(close.contains("\"closed\":true"));
2876 let after = call(
2877 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2878 );
2879 assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2880 });
2881 }
2882
2883 #[test]
2884 fn cursor_close_unknown_errors() {
2885 let rt = make_runtime();
2886 with_session(&rt, |call, _| {
2887 let resp = call(
2888 r#"{"jsonrpc":"2.0","id":1,"method":"query.close","params":{"cursor_id":9999}}"#,
2889 );
2890 assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2891 });
2892 }
2893
2894 #[test]
2895 fn cursor_next_without_cursor_id_errors() {
2896 let rt = make_runtime();
2897 with_session(&rt, |call, _| {
2898 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"query.next","params":{}}"#);
2899 assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
2900 });
2901 }
2902
2903 #[test]
2904 fn cursor_default_batch_size_returns_all_when_smaller_than_default() {
2905 let rt = make_runtime();
2906 seed_numbers_table(&rt, "nums5", 7);
2907 with_session(&rt, |call, _| {
2908 let _ = call(
2909 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums5"}}"#,
2910 );
2911 let resp =
2913 call(r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1}}"#);
2914 assert!(resp.contains("\"done\":true"));
2915 assert!(resp.contains("\"remaining\":0"));
2916 });
2917 }
2918
2919 #[test]
2920 fn close_method_drops_open_cursors() {
2921 let rt = make_runtime();
2922 seed_numbers_table(&rt, "nums6", 3);
2923 with_session(&rt, |call, _| {
2926 let _ = call(
2927 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums6"}}"#,
2928 );
2929 let close = call(r#"{"jsonrpc":"2.0","id":2,"method":"close","params":null}"#);
2930 assert!(close.contains("\"__close__\":true"));
2931 let after = call(
2933 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2934 );
2935 assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2936 });
2937 }
2938
2939 #[test]
2940 fn cursor_independent_of_transaction_state() {
2941 let rt = make_runtime();
2942 seed_numbers_table(&rt, "nums7", 4);
2943 with_session(&rt, |call, _| {
2944 let _ = call(
2946 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums7"}}"#,
2947 );
2948 let _ = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2949 let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2950 let resp = call(
2951 r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2952 );
2953 assert!(resp.contains("\"done\":true"));
2954 assert!(!resp.contains("\"error\""));
2955 });
2956 }
2957
2958 #[test]
2959 fn second_tx_after_commit_gets_fresh_id() {
2960 let rt = make_runtime();
2961 let _ = handle(
2962 &rt,
2963 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u6 (name TEXT)"}}"#,
2964 );
2965 with_session(&rt, |call, _| {
2966 let first = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2967 assert!(first.contains("\"tx_id\":1"));
2968 let _ = call(
2969 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u6","payload":{"name":"x"}}}"#,
2970 );
2971 let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2972
2973 let second = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":null}"#);
2974 assert!(second.contains("\"tx_id\":2"));
2975 let _ = call(r#"{"jsonrpc":"2.0","id":5,"method":"tx.rollback","params":null}"#);
2976 });
2977 }
2978
2979 #[test]
2980 fn prepare_and_execute_prepared_statement() {
2981 let rt = make_runtime();
2982 let _ = handle(
2984 &rt,
2985 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE ps_test (n INTEGER)"}}"#,
2986 );
2987 let _ = handle(
2988 &rt,
2989 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO ps_test (n) VALUES (42)"}}"#,
2990 );
2991
2992 with_session(&rt, |call, _| {
2993 let prep = call(
2995 r#"{"jsonrpc":"2.0","id":3,"method":"prepare","params":{"sql":"SELECT n FROM ps_test WHERE n = 42"}}"#,
2996 );
2997 assert!(prep.contains("\"prepared_id\""), "prepare response: {prep}");
2998
2999 let id: u64 = {
3001 let v: crate::json::Value = crate::json::from_str(&prep).expect("json");
3002 let result = v.get("result").expect("result");
3003 result
3004 .get("prepared_id")
3005 .and_then(|n| n.as_f64())
3006 .expect("prepared_id") as u64
3007 };
3008
3009 let exec = call(&format!(
3011 r#"{{"jsonrpc":"2.0","id":4,"method":"execute_prepared","params":{{"prepared_id":{id},"binds":[42]}}}}"#
3012 ));
3013 assert!(
3015 exec.contains("\"rows\""),
3016 "execute_prepared response: {exec}"
3017 );
3018 assert!(exec.contains("42"), "expected row with n=42 in: {exec}");
3019 });
3020 }
3021}