Skip to main content

flight_sql_client/
flight_sql_client.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! A command line client for Arrow Flight SQL.
19
20use std::{sync::Arc, time::Duration};
21
22use anyhow::{Context, Result, bail};
23use arrow_array::{ArrayRef, Datum, RecordBatch, StringArray};
24use arrow_cast::{CastOptions, cast_with_options, pretty::pretty_format_batches};
25use arrow_flight::{
26    FlightInfo,
27    flight_service_client::FlightServiceClient,
28    sql::{CommandGetDbSchemas, CommandGetTables, client::FlightSqlServiceClient},
29};
30use arrow_schema::Schema;
31use clap::{Parser, Subcommand, ValueEnum};
32use core::str;
33use futures::TryStreamExt;
34use tonic::{
35    metadata::MetadataMap,
36    transport::{Channel, ClientTlsConfig, Endpoint},
37};
38use tracing_log::log::info;
39
40/// Logging CLI config.
41#[derive(Debug, Parser)]
42pub struct LoggingArgs {
43    /// Log verbosity.
44    ///
45    /// Defaults to "warn".
46    ///
47    /// Use `-v` for "info", `-vv` for "debug", `-vvv` for "trace".
48    ///
49    /// Note you can also set logging level using `RUST_LOG` environment variable:
50    /// `RUST_LOG=debug`.
51    #[clap(
52        short = 'v',
53        long = "verbose",
54        action = clap::ArgAction::Count,
55    )]
56    log_verbose_count: u8,
57}
58
59/// gRPC/HTTP compression algorithms.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
61pub enum CompressionEncoding {
62    Gzip,
63    Deflate,
64    Zstd,
65}
66
67impl From<CompressionEncoding> for tonic::codec::CompressionEncoding {
68    fn from(encoding: CompressionEncoding) -> Self {
69        match encoding {
70            CompressionEncoding::Gzip => Self::Gzip,
71            CompressionEncoding::Deflate => Self::Deflate,
72            CompressionEncoding::Zstd => Self::Zstd,
73        }
74    }
75}
76
77#[derive(Debug, Parser)]
78struct ClientArgs {
79    /// Additional headers.
80    ///
81    /// Can be given multiple times. Headers and values are separated by '='.
82    ///
83    /// Example: `-H foo=bar -H baz=42`
84    #[clap(long = "header", short = 'H', value_parser = parse_key_val)]
85    headers: Vec<(String, String)>,
86
87    /// Username.
88    ///
89    /// Optional. If given, `password` must also be set.
90    #[clap(long, requires = "password")]
91    username: Option<String>,
92
93    /// Password.
94    ///
95    /// Optional. If given, `username` must also be set.
96    #[clap(long, requires = "username")]
97    password: Option<String>,
98
99    /// Auth token.
100    #[clap(long)]
101    token: Option<String>,
102
103    /// Use TLS.
104    ///
105    /// If not provided, use cleartext connection.
106    #[clap(long)]
107    tls: bool,
108
109    /// Dump TLS key log.
110    ///
111    /// The target file is specified by the `SSLKEYLOGFILE` environment variable.
112    ///
113    /// Requires `--tls`.
114    #[clap(long, requires = "tls")]
115    key_log: bool,
116
117    /// Server host.
118    ///
119    /// Required.
120    #[clap(long)]
121    host: String,
122
123    /// Server port.
124    ///
125    /// Defaults to `443` if `tls` is set, otherwise defaults to `80`.
126    #[clap(long)]
127    port: Option<u16>,
128
129    /// Compression accepted by the client for responses sent by the server.
130    ///
131    /// The client will send this information to the server as part of the request. The server is free to pick an
132    /// algorithm from that list or use no compression (called "identity" encoding).
133    ///
134    /// You may define multiple algorithms by using a comma-separated list.
135    #[clap(long, value_delimiter = ',')]
136    accept_compression: Vec<CompressionEncoding>,
137
138    /// Compression of requests sent by the client to the server.
139    ///
140    /// Since the client needs to decide on the compression before sending the request, there is no client<->server
141    /// negotiation. If the server does NOT support the chosen compression, it will respond with an error a la:
142    ///
143    /// ```text
144    /// Ipc error: Status {
145    ///     code: Unimplemented,
146    ///     message: "Content is compressed with `zstd` which isn't supported",
147    ///     metadata: MetadataMap { headers: {"grpc-accept-encoding": "identity", ...} },
148    ///     ...
149    /// }
150    /// ```
151    ///
152    /// Based on the algorithms listed in the `grpc-accept-encoding` header, you may make a more educated guess for
153    /// your next request. Note that `identity` is a synonym for "no compression".
154    #[clap(long)]
155    send_compression: Option<CompressionEncoding>,
156}
157
158#[derive(Debug, Parser)]
159struct Args {
160    /// Logging args.
161    #[clap(flatten)]
162    logging_args: LoggingArgs,
163
164    /// Client args.
165    #[clap(flatten)]
166    client_args: ClientArgs,
167
168    #[clap(subcommand)]
169    cmd: Command,
170}
171
172/// Different available commands.
173#[derive(Debug, Subcommand)]
174enum Command {
175    /// Get catalogs.
176    Catalogs,
177    /// Get db schemas for a catalog.
178    DbSchemas {
179        /// Name of a catalog.
180        ///
181        /// Required.
182        catalog: String,
183        /// Specifies a filter pattern for schemas to search for.
184        /// When no schema_filter is provided, the pattern will not be used to narrow the search.
185        /// In the pattern string, two special characters can be used to denote matching rules:
186        ///     - "%" means to match any substring with 0 or more characters.
187        ///     - "_" means to match any one character.
188        #[clap(short, long)]
189        db_schema_filter: Option<String>,
190    },
191    /// Get tables for a catalog.
192    Tables {
193        /// Name of a catalog.
194        ///
195        /// Required.
196        catalog: String,
197        /// Specifies a filter pattern for schemas to search for.
198        /// When no schema_filter is provided, the pattern will not be used to narrow the search.
199        /// In the pattern string, two special characters can be used to denote matching rules:
200        ///     - "%" means to match any substring with 0 or more characters.
201        ///     - "_" means to match any one character.
202        #[clap(short, long)]
203        db_schema_filter: Option<String>,
204        /// Specifies a filter pattern for tables to search for.
205        /// When no table_filter is provided, all tables matching other filters are searched.
206        /// In the pattern string, two special characters can be used to denote matching rules:
207        ///     - "%" means to match any substring with 0 or more characters.
208        ///     - "_" means to match any one character.
209        #[clap(short, long)]
210        table_filter: Option<String>,
211        /// Specifies a filter of table types which must match.
212        /// The table types depend on vendor/implementation. It is usually used to separate tables from views or system tables.
213        /// TABLE, VIEW, and SYSTEM TABLE are commonly supported.
214        #[clap(long)]
215        table_types: Vec<String>,
216    },
217    /// Get table types.
218    TableTypes,
219
220    /// Execute given statement.
221    StatementQuery {
222        /// SQL query.
223        ///
224        /// Required.
225        query: String,
226    },
227
228    /// Prepare given statement and then execute it.
229    PreparedStatementQuery {
230        /// SQL query.
231        ///
232        /// Required.
233        ///
234        /// Can contains placeholders like `$1`.
235        ///
236        /// Example: `SELECT * FROM t WHERE x = $1`
237        query: String,
238
239        /// Additional parameters.
240        ///
241        /// Can be given multiple times. Names and values are separated by '='. Values will be
242        /// converted to the type that the server reported for the prepared statement.
243        ///
244        /// Example: `-p $1=42`
245        #[clap(short, value_parser = parse_key_val)]
246        params: Vec<(String, String)>,
247    },
248}
249
250#[tokio::main]
251async fn main() -> Result<()> {
252    let args = Args::parse();
253    setup_logging(args.logging_args)?;
254    let mut client = setup_client(args.client_args)
255        .await
256        .context("setup client")?;
257
258    let flight_info = match args.cmd {
259        Command::Catalogs => client.get_catalogs().await.context("get catalogs")?,
260        Command::DbSchemas {
261            catalog,
262            db_schema_filter,
263        } => client
264            .get_db_schemas(CommandGetDbSchemas {
265                catalog: Some(catalog),
266                db_schema_filter_pattern: db_schema_filter,
267            })
268            .await
269            .context("get db schemas")?,
270        Command::Tables {
271            catalog,
272            db_schema_filter,
273            table_filter,
274            table_types,
275        } => client
276            .get_tables(CommandGetTables {
277                catalog: Some(catalog),
278                db_schema_filter_pattern: db_schema_filter,
279                table_name_filter_pattern: table_filter,
280                table_types,
281                // Schema is returned as ipc encoded bytes.
282                // We do not support returning the schema as there is no trivial mechanism
283                // to display the information to the user.
284                include_schema: false,
285            })
286            .await
287            .context("get tables")?,
288        Command::TableTypes => client.get_table_types().await.context("get table types")?,
289        Command::StatementQuery { query } => client
290            .execute(query, None)
291            .await
292            .context("execute statement")?,
293        Command::PreparedStatementQuery { query, params } => {
294            let mut prepared_stmt = client
295                .prepare(query, None)
296                .await
297                .context("prepare statement")?;
298
299            if !params.is_empty() {
300                prepared_stmt
301                    .set_parameters(
302                        construct_record_batch_from_params(
303                            &params,
304                            prepared_stmt
305                                .parameter_schema()
306                                .context("get parameter schema")?,
307                        )
308                        .context("construct parameters")?,
309                    )
310                    .context("bind parameters")?;
311            }
312
313            prepared_stmt
314                .execute()
315                .await
316                .context("execute prepared statement")?
317        }
318    };
319
320    let batches = execute_flight(&mut client, flight_info)
321        .await
322        .context("read flight data")?;
323
324    let res = pretty_format_batches(batches.as_slice()).context("format results")?;
325    println!("{res}");
326
327    Ok(())
328}
329
330async fn execute_flight(
331    client: &mut FlightSqlServiceClient<Channel>,
332    info: FlightInfo,
333) -> Result<Vec<RecordBatch>> {
334    let schema = Arc::new(Schema::try_from(info.clone()).context("valid schema")?);
335    let mut batches = Vec::with_capacity(info.endpoint.len() + 1);
336    batches.push(RecordBatch::new_empty(schema));
337    info!("decoded schema");
338
339    for endpoint in info.endpoint {
340        let Some(ticket) = &endpoint.ticket else {
341            bail!("did not get ticket");
342        };
343
344        let mut flight_data = client.do_get(ticket.clone()).await.context("do get")?;
345        log_metadata(flight_data.headers(), "header");
346
347        let mut endpoint_batches: Vec<_> = (&mut flight_data)
348            .try_collect()
349            .await
350            .context("collect data stream")?;
351        batches.append(&mut endpoint_batches);
352
353        if let Some(trailers) = flight_data.trailers() {
354            log_metadata(&trailers, "trailer");
355        }
356    }
357    info!("received data");
358
359    Ok(batches)
360}
361
362fn construct_record_batch_from_params(
363    params: &[(String, String)],
364    parameter_schema: &Schema,
365) -> Result<RecordBatch> {
366    let mut items = Vec::<(&String, ArrayRef)>::new();
367
368    for (name, value) in params {
369        let field = parameter_schema.field_with_name(name)?;
370        let value_as_array = StringArray::new_scalar(value);
371        let casted = cast_with_options(
372            value_as_array.get().0,
373            field.data_type(),
374            &CastOptions::default(),
375        )?;
376        items.push((name, casted))
377    }
378
379    Ok(RecordBatch::try_from_iter(items)?)
380}
381
382fn setup_logging(args: LoggingArgs) -> Result<()> {
383    use tracing_subscriber::{EnvFilter, FmtSubscriber, util::SubscriberInitExt};
384
385    tracing_log::LogTracer::init().context("tracing log init")?;
386
387    let filter = match args.log_verbose_count {
388        0 => "warn",
389        1 => "info",
390        2 => "debug",
391        _ => "trace",
392    };
393    let filter = EnvFilter::try_new(filter).context("set up log env filter")?;
394
395    let subscriber = FmtSubscriber::builder().with_env_filter(filter).finish();
396    subscriber.try_init().context("init logging subscriber")?;
397
398    Ok(())
399}
400
401async fn setup_client(args: ClientArgs) -> Result<FlightSqlServiceClient<Channel>> {
402    let port = args.port.unwrap_or(if args.tls { 443 } else { 80 });
403
404    let protocol = if args.tls { "https" } else { "http" };
405
406    let mut endpoint = Endpoint::new(format!("{}://{}:{}", protocol, args.host, port))
407        .context("create endpoint")?
408        .connect_timeout(Duration::from_secs(20))
409        .timeout(Duration::from_secs(20))
410        .tcp_nodelay(true) // Disable Nagle's Algorithm since we don't want packets to wait
411        .tcp_keepalive(Option::Some(Duration::from_secs(3600)))
412        .http2_keep_alive_interval(Duration::from_secs(300))
413        .keep_alive_timeout(Duration::from_secs(20))
414        .keep_alive_while_idle(true);
415
416    if args.tls {
417        let mut tls_config = ClientTlsConfig::new().with_enabled_roots();
418        if args.key_log {
419            tls_config = tls_config.use_key_log();
420        }
421
422        endpoint = endpoint
423            .tls_config(tls_config)
424            .context("create TLS endpoint")?;
425    }
426
427    let channel = endpoint.connect().await.context("connect to endpoint")?;
428
429    let mut client = FlightServiceClient::new(channel);
430    for encoding in args.accept_compression {
431        client = client.accept_compressed(encoding.into());
432    }
433    if let Some(encoding) = args.send_compression {
434        client = client.send_compressed(encoding.into());
435    }
436    let mut client = FlightSqlServiceClient::new_from_inner(client);
437    info!("connected");
438
439    for (k, v) in args.headers {
440        client.set_header(k, v);
441    }
442
443    if let Some(token) = args.token {
444        client.set_token(token);
445        info!("token set");
446    }
447
448    match (args.username, args.password) {
449        (None, None) => {}
450        (Some(username), Some(password)) => {
451            client
452                .handshake(&username, &password)
453                .await
454                .context("handshake")?;
455            info!("performed handshake");
456        }
457        (Some(_), None) => {
458            bail!("when username is set, you also need to set a password")
459        }
460        (None, Some(_)) => {
461            bail!("when password is set, you also need to set a username")
462        }
463    }
464
465    Ok(client)
466}
467
468/// Parse a single key-value pair
469fn parse_key_val(s: &str) -> Result<(String, String), String> {
470    let pos = s
471        .find('=')
472        .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
473    Ok((s[..pos].to_owned(), s[pos + 1..].to_owned()))
474}
475
476/// Log headers/trailers.
477fn log_metadata(map: &MetadataMap, what: &'static str) {
478    for k_v in map.iter() {
479        match k_v {
480            tonic::metadata::KeyAndValueRef::Ascii(k, v) => {
481                info!(
482                    "{}: {}={}",
483                    what,
484                    k.as_str(),
485                    v.to_str().unwrap_or("<invalid>"),
486                );
487            }
488            tonic::metadata::KeyAndValueRef::Binary(k, v) => {
489                info!(
490                    "{}: {}={}",
491                    what,
492                    k.as_str(),
493                    String::from_utf8_lossy(v.as_ref()),
494                );
495            }
496        }
497    }
498}