Skip to main content

netget/client/snowflake/
actions.rs

1//! Snowflake client: what the model is told and what it may do.
2
3use 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
167/// Validate an action and return the internal request it becomes.
168pub 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}