objectiveai_cli/command/db/
query.rs1use std::time::Duration;
15
16use objectiveai_sdk::cli::command::db::query::{Column, Request, Response};
17
18use crate::context::Context;
19use crate::db::query::{Column as RawColumn, RawQueryResult};
20use crate::error::Error;
21
22pub async fn execute(ctx: &Context, request: Request) -> Result<Response, Error> {
23 pre_flight_validate(&request.query)?;
24
25 let timeout = request.base.timeout_seconds.map(Duration::from_secs);
26 let raw = crate::db::query::run_readonly_query(ctx.db_client().await?, &request.query, timeout).await?;
27
28 let RawQueryResult { command_tag, columns, rows } = raw;
29 Ok(Response {
30 command_tag,
31 columns: columns
32 .into_iter()
33 .map(|RawColumn { name, r#type }| Column { name, r#type })
34 .collect(),
35 rows,
36 truncated: false,
37 })
38}
39
40fn pre_flight_validate(sql: &str) -> Result<(), Error> {
45 let trimmed = sql.trim();
46 if trimmed.is_empty() {
47 return Err(Error::InvalidQuery("empty query".to_string()));
48 }
49
50 if has_trailing_statement(sql) {
51 return Err(Error::InvalidQuery(
52 "only one statement per call".to_string(),
53 ));
54 }
55
56 let leading = leading_keyword(trimmed);
57 match leading.as_str() {
58 "COPY" => {
59 if has_copy_stdin_stdout(trimmed) {
62 return Err(Error::InvalidQuery(
63 "COPY ... TO STDOUT / FROM STDIN is not supported".to_string(),
64 ));
65 }
66 }
67 "BEGIN" | "START" | "COMMIT" | "END" | "ROLLBACK" | "SAVEPOINT" | "RELEASE" => {
68 return Err(Error::InvalidQuery(
69 "transaction control is not permitted".to_string(),
70 ));
71 }
72 _ => {}
73 }
74
75 Ok(())
76}
77
78fn leading_keyword(sql: &str) -> String {
79 sql.split(|c: char| !c.is_ascii_alphabetic())
80 .next()
81 .unwrap_or("")
82 .to_ascii_uppercase()
83}
84
85fn has_trailing_statement(sql: &str) -> bool {
89 let mut in_single = false;
90 let mut in_double = false;
91 let mut in_line_comment = false;
92 let mut in_block_comment = false;
93 let mut last_semi: Option<usize> = None;
94 let bytes = sql.as_bytes();
95 let mut i = 0;
96 while i < bytes.len() {
97 let c = bytes[i] as char;
98 if in_line_comment {
99 if c == '\n' {
100 in_line_comment = false;
101 }
102 i += 1;
103 continue;
104 }
105 if in_block_comment {
106 if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '/' {
107 in_block_comment = false;
108 i += 2;
109 continue;
110 }
111 i += 1;
112 continue;
113 }
114 if in_single {
115 if c == '\'' {
116 in_single = false;
117 }
118 i += 1;
119 continue;
120 }
121 if in_double {
122 if c == '"' {
123 in_double = false;
124 }
125 i += 1;
126 continue;
127 }
128 match c {
129 '\'' => in_single = true,
130 '"' => in_double = true,
131 '-' if i + 1 < bytes.len() && bytes[i + 1] as char == '-' => {
132 in_line_comment = true;
133 i += 2;
134 continue;
135 }
136 '/' if i + 1 < bytes.len() && bytes[i + 1] as char == '*' => {
137 in_block_comment = true;
138 i += 2;
139 continue;
140 }
141 ';' => last_semi = Some(i),
142 _ => {}
143 }
144 i += 1;
145 }
146 let Some(idx) = last_semi else { return false };
147 sql[idx + 1..].chars().any(|c| !c.is_whitespace())
148}
149
150fn has_copy_stdin_stdout(sql: &str) -> bool {
151 let upper = sql.to_ascii_uppercase();
152 upper.contains("STDIN") || upper.contains("STDOUT")
153}
154
155pub mod request_schema {
156 use objectiveai_sdk::cli::command::db::query as sdk;
157 use objectiveai_sdk::cli::command::db::query::request_schema::{Request, Response};
158
159 use crate::context::Context;
160 use crate::error::Error;
161
162 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
163 Ok(objectiveai_sdk::cli::command::ResponseSchema(
164 schemars::schema_for!(sdk::Request),
165 ))
166 }
167}
168
169pub mod response_schema {
170 use objectiveai_sdk::cli::command::db::query as sdk;
171 use objectiveai_sdk::cli::command::db::query::response_schema::{Request, Response};
172
173 use crate::context::Context;
174 use crate::error::Error;
175
176 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
177 Ok(objectiveai_sdk::cli::command::ResponseSchema(
178 schemars::schema_for!(sdk::Response),
179 ))
180 }
181}