1use std::sync::LazyLock;
4
5use anyhow::{bail, ensure, Context, Result};
6use serde_json::{json, Value};
7
8use crate::llm::actions::client_trait::{Client, ClientActionResult};
9use crate::llm::actions::protocol_trait::Protocol;
10use crate::llm::actions::{ActionDefinition, Parameter, ParameterDefinition, StartupExamples};
11use crate::protocol::log_template::LogTemplate;
12use crate::protocol::{ConnectContext, EventType};
13use crate::state::app_state::AppState;
14
15pub struct SnowflakeClientProtocol;
16
17impl SnowflakeClientProtocol {
18 pub fn new() -> Self {
19 Self
20 }
21}
22
23impl Default for SnowflakeClientProtocol {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
30 Parameter {
31 name: name.to_string(),
32 type_hint: type_hint.to_string(),
33 description: description.to_string(),
34 required,
35 }
36}
37
38fn run_sql() -> ActionDefinition {
39 ActionDefinition {
40 name: "run_sql".to_string(),
41 description: format!(
42 "Run one SQL statement in the session (query-request). Any statement: SELECT, CREATE, INSERT, UPDATE, DELETE, USE, SHOW, ALTER SESSION. The answer is snowflake_query_result with the column types and the rows decoded from them, or snowflake_error. At most max_rows rows are delivered (default {}, at most {}); truncated says whether more existed.",
43 super::DEFAULT_MAX_ROWS,
44 super::MAX_ROWS_LIMIT
45 ),
46 parameters: vec![
47 p("sql", "string", "One SQL statement, without a trailing semicolon", true),
48 p("max_rows", "number", "How many rows to deliver at most", false),
49 ],
50 example: json!({"type": "run_sql", "sql": "SELECT id, body FROM notes ORDER BY id", "max_rows": 50}),
51 log_template: Some(LogTemplate::new().with_info("-> Snowflake query-request {sql}")),
52 }
53}
54
55fn logout() -> ActionDefinition {
56 ActionDefinition {
57 name: "logout".to_string(),
58 description:
59 "End the session on the server (POST /session?delete=true) and close the client."
60 .to_string(),
61 parameters: vec![],
62 example: json!({"type": "logout"}),
63 log_template: Some(LogTemplate::new().with_info("-> Snowflake logout")),
64 }
65}
66
67pub fn actions() -> Vec<ActionDefinition> {
68 vec![run_sql(), logout()]
69}
70
71fn ev(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
72 EventType::new(id, description, run_sql().example.clone())
73 .with_parameters(params)
74 .with_actions(actions())
75}
76
77pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
78 ev(
79 "snowflake_connected",
80 "The client logged in (login-request) and holds a session; nothing has run yet.",
81 vec![
82 p("endpoint", "string", "The server's base URL", true),
83 p(
84 "user",
85 "string",
86 "The login name the session was opened with",
87 true,
88 ),
89 p(
90 "account",
91 "string",
92 "The account name sent at login, if any",
93 false,
94 ),
95 p(
96 "database",
97 "string",
98 "The session's database as the server reported it",
99 false,
100 ),
101 p(
102 "schema",
103 "string",
104 "The session's schema as the server reported it",
105 false,
106 ),
107 p(
108 "warehouse",
109 "string",
110 "The session's warehouse as the server reported it",
111 false,
112 ),
113 p(
114 "role",
115 "string",
116 "The session's role as the server reported it",
117 false,
118 ),
119 p(
120 "server_version",
121 "string",
122 "The version the server reported",
123 false,
124 ),
125 p("session_id", "string", "The server's session id", false),
126 ],
127 )
128});
129
130pub static QUERY_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
131 ev(
132 "snowflake_query_result",
133 "A statement run with run_sql succeeded.",
134 vec![
135 p("sql", "string", "The statement exactly as it was sent", true),
136 p("query_id", "string", "The server's query id", true),
137 p("columns", "array", "One {name, type, nullable, precision, scale} per column, in order", true),
138 p("rows", "array", "One object per row keyed by column name (a repeated name gets _2, _3). FIXED with scale 0 is a number, with a scale an exact decimal string; REAL a number; BOOLEAN true/false; DATE 2026-10-14; TIME 12:34:56.123; TIMESTAMP_NTZ 2026-10-14T12:34:56.123 (no zone), TIMESTAMP_LTZ the instant in UTC ending Z, TIMESTAMP_TZ the wall time with its offset; VARIANT/OBJECT/ARRAY the JSON value; BINARY {binary_length}; NULL null", true),
139 p("row_count", "number", "How many rows are in rows (at most max_rows)", true),
140 p("total", "number", "The row count the server reported (for a statement, what it affected)", false),
141 p("rows_affected", "number", "Rows inserted, updated or deleted, read from the server's 'number of rows ...' columns", false),
142 p("truncated", "boolean", "True when more rows existed than were delivered", true),
143 p("chunks_not_fetched", "number", "Result chunks the server put at other URLs; they are never fetched", true),
144 p("result_format", "string", "json or arrow: how the server sent the rows", true),
145 p("decode_warnings", "array", "Cells that did not read as their column's type, given as the server's text instead: {column, type, reason}", true),
146 p("statement_type_id", "number", "The server's statement type id", false),
147 ],
148 )
149});
150
151pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
152 ev(
153 "snowflake_error",
154 "A statement failed: the server refused it (success false, with its code and message), or the response could not be read. The session is still open.",
155 vec![
156 p("operation", "string", "The action that failed: run_sql", true),
157 p("sql", "string", "The statement that failed, exactly as it was sent", false),
158 p("code", "string", "The server's error code, e.g. 002003 (object does not exist)", false),
159 p("sql_state", "string", "The SQLSTATE, when the server gave one", false),
160 p("message", "string", "What went wrong, in the server's words or this client's", true),
161 p("query_id", "string", "The server's query id, when it gave one", false),
162 p("http_status", "number", "The HTTP status of the response", false),
163 ],
164 )
165});
166
167pub fn request(v: &Value) -> Result<(String, Value)> {
169 let kind = v
170 .get("type")
171 .and_then(Value::as_str)
172 .context("an action needs a type")?;
173 match kind {
174 "run_sql" => {
175 let sql = v
176 .get("sql")
177 .and_then(Value::as_str)
178 .context("run_sql needs sql")?
179 .trim()
180 .trim_end_matches(';')
181 .trim();
182 ensure!(!sql.is_empty(), "run_sql: sql is empty");
183 ensure!(
184 sql.len() <= super::MAX_SQL_BYTES,
185 "run_sql: the statement is {} bytes; at most {} are sent",
186 sql.len(),
187 super::MAX_SQL_BYTES
188 );
189 let max_rows = match v.get("max_rows") {
190 None | Some(Value::Null) => super::DEFAULT_MAX_ROWS as u64,
191 Some(n) => n.as_u64().context("max_rows is a whole number")?,
192 };
193 ensure!(
194 (1..=super::MAX_ROWS_LIMIT as u64).contains(&max_rows),
195 "max_rows is 1..={}",
196 super::MAX_ROWS_LIMIT
197 );
198 Ok((kind.to_string(), json!({"sql": sql, "max_rows": max_rows})))
199 }
200 "logout" => Ok((kind.to_string(), json!({}))),
201 other => bail!("Unknown Snowflake client action: {other}"),
202 }
203}
204
205fn param(
206 name: &str,
207 type_hint: &str,
208 description: &str,
209 required: bool,
210 example: Value,
211 default: Option<Value>,
212) -> ParameterDefinition {
213 ParameterDefinition {
214 name: name.into(),
215 type_hint: type_hint.into(),
216 description: description.into(),
217 required,
218 example,
219 default,
220 }
221}
222
223impl Protocol for SnowflakeClientProtocol {
224 fn protocol_name(&self) -> &'static str {
225 "Snowflake"
226 }
227 fn stack_name(&self) -> &'static str {
228 "ETH>IP>TCP>HTTP>Snowflake"
229 }
230 fn keywords(&self) -> Vec<&'static str> {
231 vec![
232 "snowflake client",
233 "connect to snowflake",
234 "snowflake warehouse",
235 "fakesnow",
236 ]
237 }
238 fn description(&self) -> &'static str {
239 "Snowflake client: logs in over the connector REST protocol, runs SQL and reads typed rows (JSON or Arrow results)"
240 }
241 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
242 actions()
243 }
244 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
245 actions()
246 }
247 fn get_event_types(&self) -> Vec<EventType> {
248 vec![
249 CONNECTED_EVENT.clone(),
250 QUERY_RESULT_EVENT.clone(),
251 ERROR_EVENT.clone(),
252 ]
253 }
254 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
255 vec![
256 param("username", "string", "The login name (LOGIN_NAME)", true, json!("analyst"), None),
257 param("password", "string", "The password (username/password authenticator). Sent in the login body; use an https:// endpoint for a real account", true, json!("secret"), None),
258 param("endpoint_url", "string", "The server's base URL, used exactly as given, e.g. http://127.0.0.1:8085. Without it the client's remote_addr is the target (host:port means http://host:port). There is no default: the client never derives an address from the account name", false, json!("http://127.0.0.1:8085"), None),
259 param("account", "string", "The account name sent at login (ACCOUNT_NAME). It never decides where the client connects", false, json!("myorg-myaccount"), None),
260 param("database", "string", "The database to open the session in (databaseName)", false, json!("ANALYTICS"), None),
261 param("schema", "string", "The schema to open the session in (schemaName)", false, json!("PUBLIC"), None),
262 param("warehouse", "string", "The warehouse for the session", false, json!("COMPUTE_WH"), None),
263 param("role", "string", "The role for the session (roleName)", false, json!("ANALYST"), None),
264 param("request_timeout_secs", "number", "Seconds a login or a statement may take, polling included (1..=3600)", false, json!(60), Some(json!(super::REQUEST_TIMEOUT.as_secs()))),
265 ]
266 }
267 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
268 use crate::protocol::metadata::*;
269 ProtocolMetadataV2::builder()
270 .state(DevelopmentState::Experimental)
271 .privilege_requirement(PrivilegeRequirement::None)
272 .implementation("Snowflake connector REST protocol over HTTP(S) (reqwest natively, the hyper transport in the browser build): POST /session/v1/login-request with the username/password authenticator and databaseName/schemaName/warehouse/roleName in the query string; POST /queries/v1/query-request with requestId and Authorization: Snowflake Token=\"...\"; query-in-progress (333333/333334) polled at the same-origin getResultUrl; an expired session (390112) renewed once with POST /session/token-request and the master token; POST /session?delete=true to log out. Results are decoded from JSON rowsets or from Arrow IPC streams (rowsetBase64, read by a hand-written bounded reader) using rowtype's logical types; result chunks at presigned URLs are counted and never fetched")
273 .llm_control("Which SQL to run, how many rows to read and when to log out; it reads typed rows, column descriptions, row counts and the server's error codes")
274 .e2e_testing("tests/client/snowflake/real_server_test.rs drives two independent servers with the model mocked: fakesnow 0.11.22 (Python, DuckDB underneath, Arrow results) and nnnkkk7/snowflake-emulator v0.0.9 (Go, DuckDB underneath, JSON results). Against fakesnow the model creates a table of nine types, inserts, reads the rows back typed, inserts a row built from the decoded values, reads a server error and logs out; snowflake-connector-python reads the table back over a second session. Against the emulator the same create/insert/select/derived-insert chain is read back over a separate REST session; each server's access log shows the logout. The follow-up bound and an LLM failure are tested against fakesnow, a refused login against the emulator; session_test.rs covers token renewal and query polling against a scripted server. Fails rather than skips without either server")
275 .notes("Experimental against the client Beta bar: both servers are independent re-implementations of Snowflake's connector protocol, not Snowflake, and each reproduces a subset of it (fakesnow answers every SELECT in Arrow, the emulator writes NULL as an empty string and DATE/TIME as Go time strings, which this client reports as decode_warnings rather than guessing). No real Snowflake account was used, so TLS, real Arrow chunking, MFA, key-pair, OAuth and SSO authenticators, async query polling and token renewal against real Snowflake are untested; polling and renewal are exercised only against a scripted server in the tests. Bound parameters, PUT/GET stage transfers and multi-statement requests are not implemented")
276 .build()
277 }
278 fn example_prompt(&self) -> &'static str {
279 "Connect to the Snowflake endpoint http://127.0.0.1:8085 as analyst, create a notes table and read it back"
280 }
281 fn get_startup_examples(&self) -> StartupExamples {
282 let llm = json!({
283 "type": "open_client",
284 "protocol": "Snowflake",
285 "remote_addr": "127.0.0.1:8085",
286 "startup_params": {"username": "analyst", "password": "secret", "database": "ANALYTICS", "schema": "PUBLIC"},
287 "instruction": "List the tables, then count the rows of each"
288 });
289 let mut static_example = llm.clone();
290 static_example["event_handlers"] = json!([
291 {"event_pattern": "snowflake_connected", "handler": {"type": "static", "actions": [
292 {"type": "run_sql", "sql": "SHOW TABLES"}
293 ]}},
294 {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
295 ]);
296 let mut scripted = static_example.clone();
297 scripted["event_handlers"][1] = json!({"event_pattern": "snowflake_query_result", "handler": {
298 "type": "script", "language": "python",
299 "code": "import json,sys\ne=json.load(sys.stdin)['event']\na=[{'type':'run_sql','sql':'SELECT COUNT(*) AS N FROM '+r['name']} for r in e.get('rows',[]) if 'name' in r][:5]\nprint(json.dumps({'actions':a}))"
300 }});
301 StartupExamples::new(llm, scripted, static_example)
302 }
303 fn group_name(&self) -> &'static str {
304 "Database"
305 }
306}
307
308impl Client for SnowflakeClientProtocol {
309 fn connect(
310 &self,
311 ctx: ConnectContext,
312 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
313 {
314 Box::pin(super::connect(ctx))
315 }
316 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
317 let (name, data) = request(&v)?;
318 Ok(ClientActionResult::Custom { name, data })
319 }
320}