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