spacetimedb_cli/subcommands/
sql.rs1use std::time::Instant;
2
3use crate::api::{from_json_seed, ClientApi, Connection, StmtResultJson};
4use crate::common_args;
5use anyhow::Context;
6use clap::{Arg, ArgAction, ArgMatches};
7use itertools::Itertools;
8use reqwest::RequestBuilder;
9use spacetimedb_lib::de::serde::SeedWrapper;
10use spacetimedb_lib::sats::{satn, Typespace};
11use tabled::settings::Style;
12
13use crate::config::Config;
14use crate::util::{database_identity, get_auth_header, ResponseExt, UNSTABLE_WARNING};
15
16pub fn cli() -> clap::Command {
17 clap::Command::new("sql")
18 .about(format!("Runs a SQL query on the database. {}", UNSTABLE_WARNING))
19 .arg(
20 Arg::new("database")
21 .required(true)
22 .help("The name or identity of the database you would like to query"),
23 )
24 .arg(
25 Arg::new("query")
26 .action(ArgAction::Set)
27 .required(true)
28 .conflicts_with("interactive")
29 .help("The SQL query to execute"),
30 )
31 .arg(
32 Arg::new("interactive")
33 .long("interactive")
34 .action(ArgAction::SetTrue)
35 .conflicts_with("query")
36 .help("Instead of using a query, run an interactive command prompt for `SQL` expressions"),
37 )
38 .arg(common_args::anonymous())
39 .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database"))
40 .arg(common_args::yes())
41}
42
43pub(crate) async fn parse_req(mut config: Config, args: &ArgMatches) -> Result<Connection, anyhow::Error> {
44 let server = args.get_one::<String>("server").map(|s| s.as_ref());
45 let force = args.get_flag("force");
46 let database_name_or_identity = args.get_one::<String>("database").unwrap();
47 let anon_identity = args.get_flag("anon_identity");
48
49 Ok(Connection {
50 host: config.get_host_url(server)?,
51 auth_header: get_auth_header(&mut config, anon_identity, server, !force).await?,
52 database_identity: database_identity(&config, database_name_or_identity, server).await?,
53 database: database_name_or_identity.to_string(),
54 })
55}
56
57fn print_row_count(rows: usize) -> String {
59 let txt = if rows == 1 { "row" } else { "rows" };
60 format!("({rows} {txt})")
61}
62
63fn print_timings(now: Instant) {
64 println!("Time: {:.2?}", now.elapsed());
65}
66
67pub(crate) async fn run_sql(builder: RequestBuilder, sql: &str, with_stats: bool) -> Result<(), anyhow::Error> {
68 let now = Instant::now();
69
70 let json = builder
71 .body(sql.to_owned())
72 .send()
73 .await?
74 .ensure_content_type("application/json")
75 .await?
76 .text()
77 .await?;
78
79 let stmt_result_json: Vec<StmtResultJson> = serde_json::from_str(&json).context("malformed sql response")?;
80
81 if stmt_result_json.is_empty() {
83 if with_stats {
84 print_timings(now);
85 }
86 println!("OK");
87 return Ok(());
88 };
89
90 stmt_result_json
91 .iter()
92 .map(|stmt_result| {
93 let mut table = stmt_result_to_table(stmt_result)?;
94 if with_stats {
95 let row_count = print_row_count(table.count_rows().wrapping_sub(1));
97 table.with(tabled::settings::panel::Footer::new(row_count));
98 }
99 anyhow::Ok(table)
100 })
101 .process_results(|it| println!("{}", it.format("\n\n")))?;
102 if with_stats {
103 print_timings(now);
104 }
105
106 Ok(())
107}
108
109fn stmt_result_to_table(stmt_result: &StmtResultJson) -> anyhow::Result<tabled::Table> {
110 let StmtResultJson { schema, rows } = stmt_result;
111
112 let mut builder = tabled::builder::Builder::default();
113 builder.set_header(
114 schema
115 .elements
116 .iter()
117 .enumerate()
118 .map(|(i, e)| e.name.clone().unwrap_or_else(|| format!("column {i}").into())),
119 );
120
121 let ty = Typespace::EMPTY.with_type(schema);
122 for row in rows {
123 let row = from_json_seed(row.get(), SeedWrapper(ty))?;
124 builder.push_record(
125 ty.with_values(&row)
126 .map(|value| satn::PsqlWrapper { ty: ty.ty(), value }.to_string()),
127 );
128 }
129
130 let mut table = builder.build();
131 table.with(Style::psql());
132
133 Ok(table)
134}
135
136pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
137 eprintln!("{}\n", UNSTABLE_WARNING);
138 let interactive = args.get_one::<bool>("interactive").unwrap_or(&false);
139 if *interactive {
140 let con = parse_req(config, args).await?;
141
142 crate::repl::exec(con).await?;
143 } else {
144 let query = args.get_one::<String>("query").unwrap();
145
146 let con = parse_req(config, args).await?;
147 let api = ClientApi::new(con);
148
149 run_sql(api.sql(), query, false).await?;
150 }
151 Ok(())
152}