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 name: name.to_string(),
1694 declared_model: crate::catalog::CollectionModel::Graph,
1695 schema_mode: crate::catalog::SchemaMode::Dynamic,
1696 origin: crate::physical::ContractOrigin::Explicit,
1697 version: 1,
1698 created_at_unix_ms: now,
1699 updated_at_unix_ms: now,
1700 default_ttl_ms: None,
1701 vector_dimension: None,
1702 vector_metric: None,
1703 context_index_fields: Vec::new(),
1704 declared_columns: Vec::new(),
1705 table_def: None,
1706 timestamps_enabled: false,
1707 context_index_enabled: false,
1708 metrics_raw_retention_ms: None,
1709 metrics_rollup_policies: Vec::new(),
1710 metrics_tenant_identity: None,
1711 metrics_namespace: None,
1712 append_only: false,
1713 subscriptions: Vec::new(),
1714 analytics_config: Vec::new(),
1715 session_key: None,
1716 session_gap_ms: None,
1717 retention_duration_ms: None,
1718 analytical_storage: None,
1719
1720 ai_policy: None,
1721 })
1722 .expect("save graph contract");
1723 }
1724
1725 fn handle(rt: &RedDBRuntime, line: &str) -> String {
1726 let mut session = Session::new();
1727 handle_line(&Backend::Local(rt), &mut session, line)
1728 }
1729
1730 fn query_request(id: u64, sql: &str) -> String {
1731 let mut params = json::Map::new();
1732 params.insert("sql".to_string(), Value::String(sql.to_string()));
1733
1734 let mut request = json::Map::new();
1735 request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1736 request.insert("id".to_string(), Value::Number(id as f64));
1737 request.insert("method".to_string(), Value::String("query".to_string()));
1738 request.insert("params".to_string(), Value::Object(params));
1739 Value::Object(request).to_string_compact()
1740 }
1741
1742 fn query_request_with_params(id: u64, sql: &str, binds: Vec<Value>) -> String {
1743 let mut params = json::Map::new();
1744 params.insert("sql".to_string(), Value::String(sql.to_string()));
1745 params.insert("params".to_string(), Value::Array(binds));
1746
1747 let mut request = json::Map::new();
1748 request.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
1749 request.insert("id".to_string(), Value::Number(id as f64));
1750 request.insert("method".to_string(), Value::String("query".to_string()));
1751 request.insert("params".to_string(), Value::Object(params));
1752 Value::Object(request).to_string_compact()
1753 }
1754
1755 fn with_session<F>(rt: &RedDBRuntime, f: F)
1758 where
1759 F: FnOnce(&dyn Fn(&str) -> String, &RedDBRuntime),
1760 {
1761 let session = std::cell::RefCell::new(Session::new());
1762 let call = |line: &str| -> String {
1763 let mut s = session.borrow_mut();
1764 handle_line(&Backend::Local(rt), &mut s, line)
1765 };
1766 f(&call, rt);
1767 }
1768
1769 fn result_rows(response: &str) -> Vec<Value> {
1770 json::from_str::<Value>(response)
1771 .expect("json response")
1772 .get("result")
1773 .and_then(|result| result.get("rows"))
1774 .and_then(Value::as_array)
1775 .map(|rows| rows.to_vec())
1776 .unwrap_or_default()
1777 }
1778
1779 fn result_name_kind(response: &str) -> Vec<(String, String)> {
1780 result_rows(response)
1781 .into_iter()
1782 .map(|row| {
1783 let object = row.as_object().expect("row object");
1784 let name = object
1785 .get("name")
1786 .and_then(Value::as_str)
1787 .expect("row name")
1788 .to_string();
1789 let kind = object
1790 .get("kind")
1791 .and_then(Value::as_str)
1792 .expect("row kind")
1793 .to_string();
1794 (name, kind)
1795 })
1796 .collect()
1797 }
1798
1799 fn json_scalar_param() -> impl Strategy<Value = Value> {
1800 prop_oneof![
1801 Just(Value::Null),
1802 any::<bool>().prop_map(Value::Bool),
1803 (-1000_i64..1000_i64).prop_map(|n| Value::Number(n as f64)),
1804 "[a-z']{0,8}".prop_map(Value::String),
1805 ]
1806 }
1807
1808 fn sql_literal_for_json(value: &Value) -> String {
1809 match value {
1810 Value::Null => "NULL".to_string(),
1811 Value::Bool(true) => "TRUE".to_string(),
1812 Value::Bool(false) => "FALSE".to_string(),
1813 Value::Number(n) => format!("{n:.0}"),
1814 Value::String(s) => format!("'{}'", s.replace('\'', "''")),
1815 _ => panic!("unsupported scalar param: {value:?}"),
1816 }
1817 }
1818
1819 #[test]
1820 fn version_method_returns_version_and_protocol() {
1821 let rt = make_runtime();
1822 let line = r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#;
1823 let resp = handle(&rt, line);
1824 assert!(resp.contains("\"id\":1"));
1825 assert!(resp.contains("\"protocol\":\"1.0\""));
1826 assert!(resp.contains("\"version\""));
1827 }
1828
1829 #[test]
1830 fn health_method_returns_ok_true() {
1831 let rt = make_runtime();
1832 let resp = handle(
1833 &rt,
1834 r#"{"jsonrpc":"2.0","id":"abc","method":"health","params":{}}"#,
1835 );
1836 assert!(resp.contains("\"ok\":true"));
1837 assert!(resp.contains("\"id\":\"abc\""));
1838 }
1839
1840 #[test]
1841 fn parse_error_for_invalid_json() {
1842 let rt = make_runtime();
1843 let resp = handle(&rt, "not json {");
1844 assert!(resp.contains("\"code\":\"PARSE_ERROR\""));
1845 assert!(resp.contains("\"id\":null"));
1846 }
1847
1848 #[test]
1849 fn invalid_request_when_method_missing() {
1850 let rt = make_runtime();
1851 let resp = handle(&rt, r#"{"jsonrpc":"2.0","id":1,"params":{}}"#);
1852 assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
1853 }
1854
1855 #[test]
1856 fn unknown_method_is_invalid_request() {
1857 let rt = make_runtime();
1858 let resp = handle(
1859 &rt,
1860 r#"{"jsonrpc":"2.0","id":1,"method":"frobnicate","params":{}}"#,
1861 );
1862 assert!(resp.contains("\"code\":\"INVALID_REQUEST\""));
1863 assert!(resp.contains("frobnicate"));
1864 }
1865
1866 #[test]
1867 fn invalid_params_when_query_sql_missing() {
1868 let rt = make_runtime();
1869 let resp = handle(
1870 &rt,
1871 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{}}"#,
1872 );
1873 assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
1874 }
1875
1876 #[test]
1877 fn query_params_reject_superseded_exact_number_envelope() {
1878 let rt = make_runtime();
1879 let resp = handle(
1880 &rt,
1881 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT $1","params":[{"$number":"1"}]}}"#,
1882 );
1883 assert!(resp.contains("\"code\":\"INVALID_PARAMS\""), "got: {resp}");
1884 assert!(
1885 resp.contains("superseded exact-number envelope"),
1886 "got: {resp}"
1887 );
1888 }
1889
1890 #[test]
1891 fn embedded_session_serves_the_stdio_protocol_without_a_process() {
1892 let rt = std::sync::Arc::new(make_runtime());
1893 let mut a = EmbeddedRpcSession::new(rt.clone());
1894 let mut b = EmbeddedRpcSession::new(rt);
1895
1896 let (resp, closed) = a.handle(r#"{"jsonrpc":"2.0","id":1,"method":"version","params":{}}"#);
1897 assert!(resp.contains("\"result\""), "got: {resp}");
1898 assert!(!closed);
1899
1900 let (resp, _) = a.handle(
1901 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"CREATE TABLE emb (id INTEGER, name TEXT)"}}"#,
1902 );
1903 assert!(resp.contains("\"result\""), "got: {resp}");
1904 let (resp, _) = a.handle(
1905 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO emb (id, name) VALUES (1, 'x')"}}"#,
1906 );
1907 assert!(resp.contains("\"result\""), "got: {resp}");
1908
1909 let (resp, _) = b.handle(
1911 r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT id, name FROM emb"}}"#,
1912 );
1913 assert!(resp.contains("\"x\""), "got: {resp}");
1914
1915 let (resp, closed) = a.handle(r#"{"jsonrpc":"2.0","id":5,"method":"close","params":{}}"#);
1916 assert!(closed, "got: {resp}");
1917 }
1918
1919 #[test]
1920 fn embedded_session_transactions_are_scoped_to_the_session() {
1921 let rt = std::sync::Arc::new(make_runtime());
1922 let mut a = EmbeddedRpcSession::new(rt.clone());
1923 let mut b = EmbeddedRpcSession::new(rt);
1924 let _ = a.handle(
1925 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE emb_tx (id INTEGER)"}}"#,
1926 );
1927
1928 let (begin, _) = a.handle(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":{}}"#);
1929 assert!(begin.contains("\"result\""), "got: {begin}");
1930 let (begin_b, _) = b.handle(r#"{"jsonrpc":"2.0","id":3,"method":"tx.begin","params":{}}"#);
1932 assert!(begin_b.contains("\"result\""), "got: {begin_b}");
1933 let (again, _) = a.handle(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":{}}"#);
1934 assert!(again.contains("TX_ALREADY_OPEN"), "got: {again}");
1935 }
1936
1937 #[test]
1938 fn close_method_marks_response_for_shutdown() {
1939 let rt = make_runtime();
1940 let resp = handle(
1941 &rt,
1942 r#"{"jsonrpc":"2.0","id":1,"method":"close","params":{}}"#,
1943 );
1944 assert!(resp.contains("\"__close__\":true"));
1945 }
1946
1947 #[test]
1948 fn query_with_int_text_params_round_trips() {
1949 let rt = make_runtime();
1950 let _ = handle(
1951 &rt,
1952 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE p (id INTEGER, name TEXT)"}}"#,
1953 );
1954 let _ = handle(
1955 &rt,
1956 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (1, 'Alice')"}}"#,
1957 );
1958 let _ = handle(
1959 &rt,
1960 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"INSERT INTO p (id, name) VALUES (2, 'Bob')"}}"#,
1961 );
1962 let resp = handle(
1963 &rt,
1964 r#"{"jsonrpc":"2.0","id":4,"method":"query","params":{"sql":"SELECT * FROM p WHERE id = $1 AND name = $2","params":[1,"Alice"]}}"#,
1965 );
1966 assert!(resp.contains("\"Alice\""), "got: {resp}");
1967 assert!(!resp.contains("\"Bob\""), "got: {resp}");
1968 }
1969
1970 #[test]
1971 fn query_with_question_params_covers_select_insert_update_delete() {
1972 let rt = make_runtime();
1973 let create = handle(
1974 &rt,
1975 &query_request(1, "CREATE TABLE qp (id INTEGER, name TEXT)"),
1976 );
1977 assert!(!create.contains("\"error\""), "got: {create}");
1978
1979 let inserted = handle(
1980 &rt,
1981 &query_request_with_params(
1982 2,
1983 "INSERT INTO qp (id, name) VALUES (?, ?)",
1984 vec![json!(1), json!("O'Reilly")],
1985 ),
1986 );
1987 assert!(inserted.contains("\"affected\":1"), "got: {inserted}");
1988
1989 let selected = handle(
1990 &rt,
1991 &query_request_with_params(3, "SELECT name FROM qp WHERE id = ?", vec![json!(1)]),
1992 );
1993 let rows = result_rows(&selected);
1994 assert_eq!(rows.len(), 1, "got: {selected}");
1995 assert_eq!(
1996 rows[0].get("name").and_then(Value::as_str),
1997 Some("O'Reilly")
1998 );
1999
2000 let selected_numbered = handle(
2001 &rt,
2002 &query_request_with_params(
2003 4,
2004 "SELECT name FROM qp WHERE name = ?1 AND id = ?2",
2005 vec![json!("O'Reilly"), json!(1)],
2006 ),
2007 );
2008 assert_eq!(
2009 result_rows(&selected_numbered).len(),
2010 1,
2011 "got: {selected_numbered}"
2012 );
2013
2014 let updated = handle(
2015 &rt,
2016 &query_request_with_params(
2017 5,
2018 "UPDATE qp SET name = ? WHERE id = ?",
2019 vec![json!("Alice"), json!(1)],
2020 ),
2021 );
2022 assert!(updated.contains("\"affected\":1"), "got: {updated}");
2023
2024 let deleted = handle(
2025 &rt,
2026 &query_request_with_params(6, "DELETE FROM qp WHERE name = ?", vec![json!("Alice")]),
2027 );
2028 assert!(deleted.contains("\"affected\":1"), "got: {deleted}");
2029
2030 let remaining = handle(&rt, &query_request(7, "SELECT * FROM qp"));
2031 assert!(result_rows(&remaining).is_empty(), "got: {remaining}");
2032 }
2033
2034 #[test]
2035 fn query_with_params_insert_and_search_round_trip() {
2036 let rt = make_runtime();
2037 let insert = handle(
2038 &rt,
2039 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"]}}"#,
2040 );
2041 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2042
2043 let search = handle(
2044 &rt,
2045 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SEARCH SIMILAR $1 COLLECTION bun_embeddings LIMIT 1","params":[[1.0,0.0]]}}"#,
2046 );
2047 assert!(search.contains("\"rows\""), "got: {search}");
2048 assert!(search.contains("\"score\":1"), "got: {search}");
2049 assert!(!search.contains("\"error\""), "got: {search}");
2050 }
2051
2052 #[test]
2053 fn query_with_question_vector_param_round_trips() {
2054 let rt = make_runtime();
2055 let insert = handle(
2056 &rt,
2057 &query_request_with_params(
2058 1,
2059 "INSERT INTO question_embeddings VECTOR (dense, content) VALUES (?, ?)",
2060 vec![json!([1.0, 0.0]), json!("question vector")],
2061 ),
2062 );
2063 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2064
2065 let search = handle(
2066 &rt,
2067 &query_request_with_params(
2068 2,
2069 "SEARCH SIMILAR ? COLLECTION question_embeddings LIMIT 1",
2070 vec![json!([1.0, 0.0])],
2071 ),
2072 );
2073 assert!(search.contains("\"rows\""), "got: {search}");
2074 assert!(search.contains("\"score\":1"), "got: {search}");
2075 assert!(!search.contains("\"error\""), "got: {search}");
2076 }
2077
2078 #[test]
2079 fn query_with_typed_json_rpc_params_round_trips() {
2080 let rt = make_runtime();
2081 let create = handle(
2082 &rt,
2083 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)"}}"#,
2084 );
2085 assert!(!create.contains("\"error\""), "got: {create}");
2086
2087 let insert = handle(
2088 &rt,
2089 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"}]}}"#,
2090 );
2091 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2092
2093 let selected = handle(
2094 &rt,
2095 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"SELECT * FROM value_params"}}"#,
2096 );
2097 assert!(selected.contains("\"ok\":true"), "got: {selected}");
2098 assert!(selected.contains("\"$float\":\"NaN\""), "got: {selected}");
2099 assert!(
2100 selected.contains("\"$bytes\":\"3q2+7w==\""),
2101 "got: {selected}"
2102 );
2103 assert!(
2104 selected.contains("\"body\":{\"a\":null,\"z\":[1,{\"a\":true}]}"),
2105 "got: {selected}"
2106 );
2107 assert!(
2108 selected.contains("\"$ts\":\"1700000000123456789\""),
2109 "got: {selected}"
2110 );
2111 assert!(
2112 selected.contains("\"$uuid\":\"00112233-4455-6677-8899-aabbccddeeff\""),
2113 "got: {selected}"
2114 );
2115 }
2116
2117 #[test]
2118 fn select_timeseries_tags_decodes_json_payload() {
2119 let rt = make_runtime();
2120 let create = handle(&rt, &query_request(1, "CREATE TIMESERIES ts1"));
2121 assert!(!create.contains("\"error\""), "got: {create}");
2122
2123 let insert = handle(
2124 &rt,
2125 &query_request(
2126 2,
2127 r#"INSERT INTO ts1 (metric, value, tags, timestamp) VALUES ('cpu', 85, '{"host":"a"}', 1000)"#,
2128 ),
2129 );
2130 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2131
2132 let selected = handle(&rt, &query_request(3, "SELECT tags FROM ts1"));
2133 assert!(!selected.contains("<json"), "got: {selected}");
2134 let response = json::from_str::<Value>(&selected).expect("response json");
2135 let tags = response
2136 .get("result")
2137 .and_then(|result| result.get("rows"))
2138 .and_then(Value::as_array)
2139 .and_then(|rows| rows.first())
2140 .and_then(|row| row.get("tags"))
2141 .expect("tags field");
2142 assert_eq!(tags, &json!({"host": "a"}));
2143 }
2144
2145 #[test]
2146 fn select_table_json_column_round_trips_after_single_parse() {
2147 let rt = make_runtime();
2148 let create = handle(&rt, &query_request(1, "CREATE TABLE docs (payload JSON)"));
2149 assert!(!create.contains("\"error\""), "got: {create}");
2150
2151 let original = r#"{"nested":{"items":[1,true,"x"],"object":{"k":"v"}}}"#;
2152 let insert_sql = format!("INSERT INTO docs (payload) VALUES ({original})");
2153 let insert = handle(&rt, &query_request(2, &insert_sql));
2154 assert!(insert.contains("\"affected\":1"), "got: {insert}");
2155
2156 let selected = handle(&rt, &query_request(3, "SELECT payload FROM docs"));
2157 assert!(!selected.contains("<json"), "got: {selected}");
2158 let response = json::from_str::<Value>(&selected).expect("response json");
2159 let payload = response
2160 .get("result")
2161 .and_then(|result| result.get("rows"))
2162 .and_then(Value::as_array)
2163 .and_then(|rows| rows.first())
2164 .and_then(|row| row.get("payload"))
2165 .expect("payload field");
2166 let expected = json::from_str::<Value>(original).expect("expected json");
2167 assert_eq!(payload, &expected);
2168
2169 let payload_text = payload.to_string_compact();
2170 assert_eq!(
2171 json::from_str::<Value>(&payload_text).expect("single parse"),
2172 expected
2173 );
2174 }
2175
2176 #[test]
2177 fn select_json_corruption_falls_back_to_code_and_hex() {
2178 use crate::storage::query::unified::UnifiedResult;
2179
2180 let mut result = UnifiedResult::with_columns(vec!["payload".into()]);
2181 let mut record = UnifiedRecord::new();
2182 record.set("payload", SchemaValue::Json(b"{not json".to_vec()));
2183 result.push(record);
2184
2185 let json = query_result_to_json(&RuntimeQueryResult {
2186 query: "SELECT payload FROM docs".to_string(),
2187 mode: reddb_rql::modes::QueryMode::Sql,
2188 statement: "select",
2189 engine: "runtime-table",
2190 result,
2191 affected_rows: 0,
2192 statement_type: "select",
2193 bookmark: None,
2194 notice: None,
2195 });
2196
2197 let payload = json
2198 .get("rows")
2199 .and_then(Value::as_array)
2200 .and_then(|rows| rows.first())
2201 .and_then(|row| row.get("payload"))
2202 .expect("payload field");
2203 assert_eq!(
2204 payload.get("code").and_then(Value::as_str),
2205 Some("INVALID_JSON")
2206 );
2207 assert_eq!(
2208 payload.get("hex").and_then(Value::as_str),
2209 Some("7b6e6f74206a736f6e")
2210 );
2211 }
2212
2213 #[test]
2214 fn query_result_json_preserves_exact_numbers() {
2215 use crate::storage::query::unified::UnifiedResult;
2216
2217 let mut result =
2218 UnifiedResult::with_columns(vec!["big".into(), "unsigned".into(), "decimal".into()]);
2219 let mut record = UnifiedRecord::new();
2220 record.set("big", SchemaValue::Integer(9_007_199_254_740_993));
2221 record.set(
2222 "unsigned",
2223 SchemaValue::UnsignedInteger(i64::MAX as u64 + 1),
2224 );
2225 record.set(
2226 "decimal",
2227 SchemaValue::DecimalText("3.14159265358979323846".to_string()),
2228 );
2229 result.push(record);
2230
2231 let json = query_result_to_json(&RuntimeQueryResult {
2232 query: "SELECT exact".to_string(),
2233 mode: reddb_rql::modes::QueryMode::Sql,
2234 statement: "select",
2235 engine: "runtime-table",
2236 result,
2237 affected_rows: 0,
2238 statement_type: "select",
2239 bookmark: None,
2240 notice: None,
2241 });
2242
2243 let row = json
2244 .get("rows")
2245 .and_then(Value::as_array)
2246 .and_then(|rows| rows.first())
2247 .expect("row");
2248 assert_eq!(
2249 row.get("big"),
2250 Some(&single_key_object(
2251 "$int",
2252 Value::String("9007199254740993".to_string())
2253 ))
2254 );
2255 assert_eq!(
2256 row.get("unsigned"),
2257 Some(&single_key_object(
2258 "$uint",
2259 Value::String("9223372036854775808".to_string())
2260 ))
2261 );
2262 assert_eq!(
2263 row.get("decimal"),
2264 Some(&single_key_object(
2265 "$decimal",
2266 Value::String("3.14159265358979323846".to_string())
2267 ))
2268 );
2269 }
2270
2271 #[test]
2272 fn query_with_params_arity_mismatch_rejected() {
2273 let rt = make_runtime();
2274 let _ = handle(
2275 &rt,
2276 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pa (id INTEGER)"}}"#,
2277 );
2278 let resp = handle(
2279 &rt,
2280 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pa WHERE id = $1","params":[1,2]}}"#,
2281 );
2282 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2283 }
2284
2285 #[test]
2286 fn query_with_question_params_arity_mismatch_rejected() {
2287 let rt = make_runtime();
2288 let _ = handle(&rt, &query_request(1, "CREATE TABLE qpa (id INTEGER)"));
2289 let resp = handle(
2290 &rt,
2291 &query_request_with_params(
2292 2,
2293 "SELECT * FROM qpa WHERE id = ?",
2294 vec![json!(1), json!(2)],
2295 ),
2296 );
2297 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2298 assert!(resp.contains("SQL expects 1, got 2"), "got: {resp}");
2299 }
2300
2301 #[test]
2302 fn query_with_params_gap_rejected() {
2303 let rt = make_runtime();
2304 let _ = handle(
2305 &rt,
2306 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE pg (a INTEGER, b INTEGER)"}}"#,
2307 );
2308 let resp = handle(
2309 &rt,
2310 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"SELECT * FROM pg WHERE a = $1 AND b = $3","params":[1,2,3]}}"#,
2311 );
2312 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2313 }
2314
2315 #[test]
2316 fn query_with_question_numbered_gap_rejected() {
2317 let rt = make_runtime();
2318 let _ = handle(&rt, &query_request(1, "CREATE TABLE qpg (id INTEGER)"));
2319 let resp = handle(
2320 &rt,
2321 &query_request_with_params(
2322 2,
2323 "SELECT * FROM qpg WHERE id = ?2",
2324 vec![json!(1), json!(2)],
2325 ),
2326 );
2327 assert!(resp.contains("\"INVALID_PARAMS\""), "got: {resp}");
2328 assert!(resp.contains("parameter $`1` is missing"), "got: {resp}");
2329 }
2330
2331 #[test]
2332 fn query_with_question_params_type_mismatch_names_slot() {
2333 let rt = make_runtime();
2334 let _ = handle(&rt, &query_request(1, "CREATE TABLE qpt (id INTEGER)"));
2335 let resp = handle(
2336 &rt,
2337 &query_request_with_params(
2338 2,
2339 "INSERT INTO qpt (id) VALUES (?)",
2340 vec![json!("not-an-integer")],
2341 ),
2342 );
2343 assert!(resp.contains("\"QUERY_ERROR\""), "got: {resp}");
2344 assert!(resp.contains("id"), "got: {resp}");
2345 assert!(resp.contains("integer"), "got: {resp}");
2346 }
2347
2348 #[test]
2349 fn query_select_one_returns_rows() {
2350 let rt = make_runtime();
2351 let resp = handle(
2352 &rt,
2353 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"SELECT 1 AS one"}}"#,
2354 );
2355 assert!(resp.contains("\"result\""));
2356 assert!(!resp.contains("\"error\""));
2357 }
2358
2359 #[test]
2360 fn ask_query_result_uses_canonical_envelope() {
2361 use crate::storage::query::unified::UnifiedResult;
2362
2363 let mut result = UnifiedResult::with_columns(vec![
2364 "answer".into(),
2365 "provider".into(),
2366 "model".into(),
2367 "prompt_tokens".into(),
2368 "completion_tokens".into(),
2369 "sources_count".into(),
2370 "sources_flat".into(),
2371 "citations".into(),
2372 "validation".into(),
2373 ]);
2374 let mut record = UnifiedRecord::new();
2375 record.set("answer", SchemaValue::text("Deploy failed [^1]."));
2376 record.set("provider", SchemaValue::text("openai"));
2377 record.set("model", SchemaValue::text("gpt-4o-mini"));
2378 record.set("prompt_tokens", SchemaValue::Integer(11));
2379 record.set("completion_tokens", SchemaValue::Integer(7));
2380 record.set(
2381 "sources_flat",
2382 SchemaValue::Json(
2383 br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
2384 ),
2385 );
2386 record.set(
2387 "citations",
2388 SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
2389 );
2390 record.set(
2391 "validation",
2392 SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
2393 );
2394 result.push(record);
2395
2396 let json = query_result_to_json(&RuntimeQueryResult {
2397 query: "ASK 'why did deploy fail?'".to_string(),
2398 mode: reddb_rql::modes::QueryMode::Sql,
2399 statement: "ask",
2400 engine: "runtime-ai",
2401 result,
2402 affected_rows: 0,
2403 statement_type: "select",
2404 bookmark: None,
2405 notice: None,
2406 });
2407
2408 assert_eq!(
2409 json.get("answer").and_then(Value::as_str),
2410 Some("Deploy failed [^1].")
2411 );
2412 assert_eq!(json.get("cache_hit").and_then(Value::as_bool), Some(false));
2413 assert_eq!(json.get("cost_usd").and_then(Value::as_f64), Some(0.0));
2414 assert_eq!(json.get("mode").and_then(Value::as_str), Some("strict"));
2415 assert_eq!(json.get("retry_count").and_then(Value::as_u64), Some(0));
2416 assert!(
2417 json.get("rows").is_none(),
2418 "ASK envelope must not be row-wrapped: {json}"
2419 );
2420 assert!(
2421 json.get("sources_flat")
2422 .and_then(Value::as_array)
2423 .is_some_and(|sources| sources.len() == 1
2424 && sources[0].get("payload").and_then(Value::as_str).is_some()),
2425 "sources_flat must be a parsed array: {json}"
2426 );
2427 assert!(
2428 json.get("citations")
2429 .and_then(Value::as_array)
2430 .is_some_and(|citations| citations.len() == 1),
2431 "citations must be a parsed array: {json}"
2432 );
2433 assert_eq!(
2434 json.get("validation")
2435 .and_then(|v| v.get("ok"))
2436 .and_then(Value::as_bool),
2437 Some(true)
2438 );
2439 }
2440
2441 #[test]
2446 fn tx_begin_returns_tx_id_and_isolation() {
2447 let rt = make_runtime();
2448 with_session(&rt, |call, _| {
2449 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2450 assert!(resp.contains("\"tx_id\":1"));
2451 assert!(resp.contains("\"isolation\":\"read_committed_deferred\""));
2452 assert!(!resp.contains("\"error\""));
2453 });
2454 }
2455
2456 #[test]
2457 fn tx_begin_twice_returns_already_open() {
2458 let rt = make_runtime();
2459 with_session(&rt, |call, _| {
2460 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2461 let resp = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2462 assert!(resp.contains("\"code\":\"TX_ALREADY_OPEN\""));
2463 });
2464 }
2465
2466 #[test]
2467 fn tx_commit_without_begin_returns_no_tx_open() {
2468 let rt = make_runtime();
2469 with_session(&rt, |call, _| {
2470 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.commit","params":null}"#);
2471 assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2472 });
2473 }
2474
2475 #[test]
2476 fn tx_rollback_without_begin_returns_no_tx_open() {
2477 let rt = make_runtime();
2478 with_session(&rt, |call, _| {
2479 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.rollback","params":null}"#);
2480 assert!(resp.contains("\"code\":\"NO_TX_OPEN\""));
2481 });
2482 }
2483
2484 #[test]
2485 fn insert_inside_tx_returns_pending_envelope() {
2486 let rt = make_runtime();
2487 let _ = handle(
2489 &rt,
2490 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE users (name TEXT)"}}"#,
2491 );
2492 with_session(&rt, |call, _| {
2493 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2494 let resp = call(
2495 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"users","payload":{"name":"alice"}}}"#,
2496 );
2497 assert!(resp.contains("\"pending\":true"));
2498 assert!(resp.contains("\"tx_id\":1"));
2499 assert!(resp.contains("\"affected\":0"));
2500 });
2501 }
2502
2503 #[test]
2504 fn begin_insert_rollback_does_not_persist() {
2505 let rt = make_runtime();
2506 let _ = handle(
2507 &rt,
2508 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u (name TEXT)"}}"#,
2509 );
2510 with_session(&rt, |call, _| {
2511 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2512 let _ = call(
2513 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u","payload":{"name":"ghost"}}}"#,
2514 );
2515 let rollback = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2516 assert!(rollback.contains("\"ops_discarded\":1"));
2517 assert!(rollback.contains("\"tx_id\":1"));
2518 });
2519 let resp = handle(
2521 &rt,
2522 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u"}}"#,
2523 );
2524 assert!(!resp.contains("\"ghost\""));
2525 }
2526
2527 #[test]
2528 fn begin_insert_commit_persists() {
2529 let rt = make_runtime();
2530 let _ = handle(
2531 &rt,
2532 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u2 (name TEXT)"}}"#,
2533 );
2534 with_session(&rt, |call, _| {
2535 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2536 let _ = call(
2537 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u2","payload":{"name":"alice"}}}"#,
2538 );
2539 let _ = call(
2540 r#"{"jsonrpc":"2.0","id":3,"method":"insert","params":{"collection":"u2","payload":{"name":"bob"}}}"#,
2541 );
2542 let commit = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.commit","params":null}"#);
2543 assert!(commit.contains("\"ops_replayed\":2"));
2544 assert!(!commit.contains("\"error\""));
2545 });
2546 let resp = handle(
2547 &rt,
2548 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u2"}}"#,
2549 );
2550 assert!(resp.contains("\"alice\""));
2551 assert!(resp.contains("\"bob\""));
2552 }
2553
2554 #[test]
2555 fn bulk_insert_inside_tx_buffers_everything() {
2556 let rt = make_runtime();
2557 let _ = handle(
2558 &rt,
2559 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u3 (name TEXT)"}}"#,
2560 );
2561 with_session(&rt, |call, _| {
2562 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2563 let resp = call(
2564 r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"u3","payloads":[{"name":"a"},{"name":"b"},{"name":"c"}]}}"#,
2565 );
2566 assert!(resp.contains("\"buffered\":3"));
2567 assert!(resp.contains("\"pending\":true"));
2568 assert!(resp.contains("\"affected\":0"));
2569
2570 let commit = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2571 assert!(commit.contains("\"ops_replayed\":3"));
2572 });
2573 }
2574
2575 #[test]
2576 fn bulk_insert_chunks_at_internal_500_row_limit() {
2577 assert_eq!(bulk_insert_chunk_count(0), 0);
2578 assert_eq!(bulk_insert_chunk_count(1), 1);
2579 assert_eq!(bulk_insert_chunk_count(500), 1);
2580 assert_eq!(bulk_insert_chunk_count(501), 2);
2581 assert_eq!(bulk_insert_chunk_count(1000), 2);
2582 assert_eq!(bulk_insert_chunk_count(1001), 3);
2583 }
2584
2585 proptest! {
2586 #![proptest_config(ProptestConfig {
2587 cases: 12,
2588 ..ProptestConfig::default()
2589 })]
2590
2591 #[test]
2592 fn bulk_insert_matches_sequential_insert_state(
2593 names in proptest::collection::vec("[a-z]{1,8}", 1usize..20)
2594 ) {
2595 let rt = make_runtime();
2596 let payloads = names
2597 .iter()
2598 .map(|name| format!(r#"{{"name":"{name}","kind":"bulk"}}"#))
2599 .collect::<Vec<_>>();
2600 let payload_array = payloads.join(",");
2601
2602 let bulk = handle(
2603 &rt,
2604 &format!(
2605 r#"{{"jsonrpc":"2.0","id":1,"method":"bulk_insert","params":{{"collection":"bulk_prop","payloads":[{payload_array}]}}}}"#
2606 ),
2607 );
2608 let bulk_result = json::from_str::<Value>(&bulk).expect("bulk json");
2609 let bulk_ids = bulk_result
2610 .get("result")
2611 .and_then(|result| result.get("ids"))
2612 .and_then(Value::as_array)
2613 .expect("bulk ids");
2614 prop_assert_eq!(bulk_ids.len(), names.len());
2615
2616 for (index, payload) in payloads.iter().enumerate() {
2617 let insert = handle(
2618 &rt,
2619 &format!(
2620 r#"{{"jsonrpc":"2.0","id":{},"method":"insert","params":{{"collection":"seq_prop","payload":{payload}}}}}"#,
2621 index + 10
2622 ),
2623 );
2624 let insert_result = json::from_str::<Value>(&insert).expect("insert json");
2625 prop_assert!(
2626 insert_result
2627 .get("result")
2628 .and_then(|result| result.get("id"))
2629 .is_some(),
2630 "insert response missing id: {insert}"
2631 );
2632 }
2633
2634 let bulk_rows = result_name_kind(&handle(
2635 &rt,
2636 r#"{"jsonrpc":"2.0","id":99,"method":"query","params":{"sql":"SELECT name, kind FROM bulk_prop ORDER BY rid"}}"#,
2637 ));
2638 let seq_rows = result_name_kind(&handle(
2639 &rt,
2640 r#"{"jsonrpc":"2.0","id":100,"method":"query","params":{"sql":"SELECT name, kind FROM seq_prop ORDER BY rid"}}"#,
2641 ));
2642 prop_assert_eq!(bulk_rows, seq_rows);
2643 }
2644
2645 #[test]
2646 fn question_param_select_matches_inlined_literal(value in json_scalar_param()) {
2647 let rt = make_runtime();
2648 let bound = handle(
2649 &rt,
2650 &query_request_with_params(1, "SELECT ? AS v", vec![value.clone()]),
2651 );
2652 let inline_sql = format!("SELECT {} AS v", sql_literal_for_json(&value));
2653 let inlined = handle(&rt, &query_request(2, &inline_sql));
2654 prop_assert_eq!(
2655 result_rows(&bound),
2656 result_rows(&inlined),
2657 "bound={}, inlined={}",
2658 bound,
2659 inlined
2660 );
2661 }
2662 }
2663
2664 #[test]
2665 fn bulk_insert_graph_nodes_accepts_flat_rows_and_returns_ids() {
2666 let rt = make_runtime();
2667 create_graph_collection(&rt, "social");
2668
2669 let resp = handle(
2670 &rt,
2671 r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"social","payloads":[{"label":"User","name":"alice"},{"label":"User","name":"bob"}]}}"#,
2672 );
2673 let envelope: Value = json::from_str(&resp).expect("json response");
2674 let result = envelope.get("result").expect("result");
2675 assert_eq!(result.get("affected").and_then(Value::as_u64), Some(2));
2676 assert_eq!(
2677 result
2678 .get("ids")
2679 .and_then(Value::as_array)
2680 .map(|ids| ids.len()),
2681 Some(2)
2682 );
2683
2684 let query = handle(
2685 &rt,
2686 r#"{"jsonrpc":"2.0","id":3,"method":"query","params":{"sql":"MATCH (n:User) RETURN n.name"}}"#,
2687 );
2688 assert!(query.contains("\"alice\""), "got: {query}");
2689 assert!(query.contains("\"bob\""), "got: {query}");
2690 }
2691
2692 #[test]
2693 fn bulk_insert_graph_edges_accepts_flat_rows_and_returns_ids() {
2694 let rt = make_runtime();
2695 create_graph_collection(&rt, "network");
2696 let nodes = handle(
2697 &rt,
2698 r#"{"jsonrpc":"2.0","id":2,"method":"bulk_insert","params":{"collection":"network","payloads":[{"label":"Host","name":"app"},{"label":"Host","name":"db"}]}}"#,
2699 );
2700 let envelope: Value = json::from_str(&nodes).expect("node response");
2701 let ids = envelope
2702 .get("result")
2703 .and_then(|r| r.get("ids"))
2704 .and_then(Value::as_array)
2705 .expect("node ids");
2706 let from = ids[0].as_u64().expect("from id");
2707 let to = ids[1].as_u64().expect("to id");
2708
2709 let resp = handle(
2710 &rt,
2711 &format!(
2712 r#"{{"jsonrpc":"2.0","id":3,"method":"bulk_insert","params":{{"collection":"network","payloads":[{{"label":"connects","from":{from},"to":{to},"weight":0.5,"role":"primary"}}]}}}}"#
2713 ),
2714 );
2715 let envelope: Value = json::from_str(&resp).expect("edge response");
2716 let result = envelope.get("result").expect("result");
2717 assert_eq!(result.get("affected").and_then(Value::as_u64), Some(1));
2718 assert_eq!(
2719 result
2720 .get("ids")
2721 .and_then(Value::as_array)
2722 .map(|ids| ids.len()),
2723 Some(1)
2724 );
2725 }
2726
2727 #[test]
2728 fn delete_inside_tx_is_buffered() {
2729 let rt = make_runtime();
2730 let _ = handle(
2732 &rt,
2733 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u4 (name TEXT)"}}"#,
2734 );
2735 let _ = handle(
2736 &rt,
2737 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO u4 (name) VALUES ('keep')"}}"#,
2738 );
2739 with_session(&rt, |call, _| {
2740 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2741 let resp = call(
2742 r#"{"jsonrpc":"2.0","id":2,"method":"delete","params":{"collection":"u4","id":"1"}}"#,
2743 );
2744 assert!(resp.contains("\"pending\":true"));
2745 let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.rollback","params":null}"#);
2746 });
2747 let resp = handle(
2749 &rt,
2750 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u4"}}"#,
2751 );
2752 assert!(resp.contains("\"keep\""));
2753 }
2754
2755 #[test]
2756 fn close_with_open_tx_auto_rollbacks() {
2757 let rt = make_runtime();
2758 let _ = handle(
2759 &rt,
2760 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u5 (name TEXT)"}}"#,
2761 );
2762 with_session(&rt, |call, _| {
2763 let _ = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2764 let _ = call(
2765 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u5","payload":{"name":"ghost"}}}"#,
2766 );
2767 let close = call(r#"{"jsonrpc":"2.0","id":3,"method":"close","params":null}"#);
2768 assert!(close.contains("\"__close__\":true"));
2769 assert!(!close.contains("\"error\""));
2770 });
2771 let resp = handle(
2772 &rt,
2773 r#"{"jsonrpc":"2.0","id":9,"method":"query","params":{"sql":"SELECT * FROM u5"}}"#,
2774 );
2775 assert!(!resp.contains("\"ghost\""));
2776 }
2777
2778 fn seed_numbers_table(rt: &RedDBRuntime, table: &str, count: u32) {
2783 let _ = handle(
2784 rt,
2785 &format!(
2786 r#"{{"jsonrpc":"2.0","id":1,"method":"query","params":{{"sql":"CREATE TABLE {table} (n INTEGER)"}}}}"#,
2787 ),
2788 );
2789 for i in 0..count {
2790 let _ = handle(
2791 rt,
2792 &format!(
2793 r#"{{"jsonrpc":"2.0","id":2,"method":"query","params":{{"sql":"INSERT INTO {table} (n) VALUES ({i})"}}}}"#,
2794 ),
2795 );
2796 }
2797 }
2798
2799 #[test]
2800 fn cursor_open_returns_id_columns_and_total() {
2801 let rt = make_runtime();
2802 seed_numbers_table(&rt, "nums1", 3);
2803 with_session(&rt, |call, _| {
2804 let resp = call(
2805 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums1"}}"#,
2806 );
2807 assert!(resp.contains("\"cursor_id\":1"));
2808 assert!(resp.contains("\"total_rows\":3"));
2809 assert!(resp.contains("\"columns\""));
2810 assert!(!resp.contains("\"error\""));
2811 });
2812 }
2813
2814 #[test]
2815 fn cursor_next_chunks_rows_and_signals_done() {
2816 let rt = make_runtime();
2817 seed_numbers_table(&rt, "nums2", 5);
2818 with_session(&rt, |call, _| {
2819 let _ = call(
2820 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums2"}}"#,
2821 );
2822 let first = call(
2823 r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2824 );
2825 assert!(first.contains("\"done\":false"));
2826 assert!(first.contains("\"remaining\":3"));
2827
2828 let second = call(
2829 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2830 );
2831 assert!(second.contains("\"done\":false"));
2832 assert!(second.contains("\"remaining\":1"));
2833
2834 let third = call(
2835 r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":2}}"#,
2836 );
2837 assert!(third.contains("\"done\":true"));
2838 assert!(third.contains("\"remaining\":0"));
2839 });
2840 }
2841
2842 #[test]
2843 fn cursor_auto_drops_when_exhausted() {
2844 let rt = make_runtime();
2845 seed_numbers_table(&rt, "nums3", 2);
2846 with_session(&rt, |call, _| {
2847 let _ = call(
2848 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums3"}}"#,
2849 );
2850 let _ = call(
2851 r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2852 );
2853 let resp = call(
2856 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":100}}"#,
2857 );
2858 assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2859 });
2860 }
2861
2862 #[test]
2863 fn cursor_close_removes_it() {
2864 let rt = make_runtime();
2865 seed_numbers_table(&rt, "nums4", 3);
2866 with_session(&rt, |call, _| {
2867 let _ = call(
2868 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums4"}}"#,
2869 );
2870 let close =
2871 call(r#"{"jsonrpc":"2.0","id":2,"method":"query.close","params":{"cursor_id":1}}"#);
2872 assert!(close.contains("\"closed\":true"));
2873 let after = call(
2874 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2875 );
2876 assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2877 });
2878 }
2879
2880 #[test]
2881 fn cursor_close_unknown_errors() {
2882 let rt = make_runtime();
2883 with_session(&rt, |call, _| {
2884 let resp = call(
2885 r#"{"jsonrpc":"2.0","id":1,"method":"query.close","params":{"cursor_id":9999}}"#,
2886 );
2887 assert!(resp.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2888 });
2889 }
2890
2891 #[test]
2892 fn cursor_next_without_cursor_id_errors() {
2893 let rt = make_runtime();
2894 with_session(&rt, |call, _| {
2895 let resp = call(r#"{"jsonrpc":"2.0","id":1,"method":"query.next","params":{}}"#);
2896 assert!(resp.contains("\"code\":\"INVALID_PARAMS\""));
2897 });
2898 }
2899
2900 #[test]
2901 fn cursor_default_batch_size_returns_all_when_smaller_than_default() {
2902 let rt = make_runtime();
2903 seed_numbers_table(&rt, "nums5", 7);
2904 with_session(&rt, |call, _| {
2905 let _ = call(
2906 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums5"}}"#,
2907 );
2908 let resp =
2910 call(r#"{"jsonrpc":"2.0","id":2,"method":"query.next","params":{"cursor_id":1}}"#);
2911 assert!(resp.contains("\"done\":true"));
2912 assert!(resp.contains("\"remaining\":0"));
2913 });
2914 }
2915
2916 #[test]
2917 fn close_method_drops_open_cursors() {
2918 let rt = make_runtime();
2919 seed_numbers_table(&rt, "nums6", 3);
2920 with_session(&rt, |call, _| {
2923 let _ = call(
2924 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums6"}}"#,
2925 );
2926 let close = call(r#"{"jsonrpc":"2.0","id":2,"method":"close","params":null}"#);
2927 assert!(close.contains("\"__close__\":true"));
2928 let after = call(
2930 r#"{"jsonrpc":"2.0","id":3,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2931 );
2932 assert!(after.contains("\"code\":\"CURSOR_NOT_FOUND\""));
2933 });
2934 }
2935
2936 #[test]
2937 fn cursor_independent_of_transaction_state() {
2938 let rt = make_runtime();
2939 seed_numbers_table(&rt, "nums7", 4);
2940 with_session(&rt, |call, _| {
2941 let _ = call(
2943 r#"{"jsonrpc":"2.0","id":1,"method":"query.open","params":{"sql":"SELECT n FROM nums7"}}"#,
2944 );
2945 let _ = call(r#"{"jsonrpc":"2.0","id":2,"method":"tx.begin","params":null}"#);
2946 let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2947 let resp = call(
2948 r#"{"jsonrpc":"2.0","id":4,"method":"query.next","params":{"cursor_id":1,"batch_size":10}}"#,
2949 );
2950 assert!(resp.contains("\"done\":true"));
2951 assert!(!resp.contains("\"error\""));
2952 });
2953 }
2954
2955 #[test]
2956 fn second_tx_after_commit_gets_fresh_id() {
2957 let rt = make_runtime();
2958 let _ = handle(
2959 &rt,
2960 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE u6 (name TEXT)"}}"#,
2961 );
2962 with_session(&rt, |call, _| {
2963 let first = call(r#"{"jsonrpc":"2.0","id":1,"method":"tx.begin","params":null}"#);
2964 assert!(first.contains("\"tx_id\":1"));
2965 let _ = call(
2966 r#"{"jsonrpc":"2.0","id":2,"method":"insert","params":{"collection":"u6","payload":{"name":"x"}}}"#,
2967 );
2968 let _ = call(r#"{"jsonrpc":"2.0","id":3,"method":"tx.commit","params":null}"#);
2969
2970 let second = call(r#"{"jsonrpc":"2.0","id":4,"method":"tx.begin","params":null}"#);
2971 assert!(second.contains("\"tx_id\":2"));
2972 let _ = call(r#"{"jsonrpc":"2.0","id":5,"method":"tx.rollback","params":null}"#);
2973 });
2974 }
2975
2976 #[test]
2977 fn prepare_and_execute_prepared_statement() {
2978 let rt = make_runtime();
2979 let _ = handle(
2981 &rt,
2982 r#"{"jsonrpc":"2.0","id":1,"method":"query","params":{"sql":"CREATE TABLE ps_test (n INTEGER)"}}"#,
2983 );
2984 let _ = handle(
2985 &rt,
2986 r#"{"jsonrpc":"2.0","id":2,"method":"query","params":{"sql":"INSERT INTO ps_test (n) VALUES (42)"}}"#,
2987 );
2988
2989 with_session(&rt, |call, _| {
2990 let prep = call(
2992 r#"{"jsonrpc":"2.0","id":3,"method":"prepare","params":{"sql":"SELECT n FROM ps_test WHERE n = 42"}}"#,
2993 );
2994 assert!(prep.contains("\"prepared_id\""), "prepare response: {prep}");
2995
2996 let id: u64 = {
2998 let v: crate::json::Value = crate::json::from_str(&prep).expect("json");
2999 let result = v.get("result").expect("result");
3000 result
3001 .get("prepared_id")
3002 .and_then(|n| n.as_f64())
3003 .expect("prepared_id") as u64
3004 };
3005
3006 let exec = call(&format!(
3008 r#"{{"jsonrpc":"2.0","id":4,"method":"execute_prepared","params":{{"prepared_id":{id},"binds":[42]}}}}"#
3009 ));
3010 assert!(
3012 exec.contains("\"rows\""),
3013 "execute_prepared response: {exec}"
3014 );
3015 assert!(exec.contains("42"), "expected row with n=42 in: {exec}");
3016 });
3017 }
3018}