Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

skade-flight/tests/flight_sql_roundtrip.rs

//! End to end over real TCP with the stock `FlightSqlServiceClient` — the
//! same handshake, introspection and query shape Power BI's connector and
//! the Flight SQL ODBC driver drive.

use std::sync::Arc;
use std::time::Duration;

use arrow_array::{Float64Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use skade_flight::arrow_flight::sql::client::FlightSqlServiceClient;
use skade_flight::arrow_flight::sql::{CommandGetDbSchemas, CommandGetTables, CommandGetXdbcTypeInfo};
use skade_flight::{Auth, Engine};
use futures::TryStreamExt;
use tonic::transport::Channel;

fn people() -> (Schema, RecordBatch) {
    let schema = Schema::new(vec![
        Field::new("id", DataType::Int64, false),
        Field::new("name", DataType::Utf8, false),
        Field::new("score", DataType::Float64, true),
    ]);
    let batch = RecordBatch::try_new(
        Arc::new(schema.clone()),
        vec![
            Arc::new(Int64Array::from(vec![1, 2, 3, 4, 5])),
            Arc::new(StringArray::from(vec!["ada", "bob", "cyd", "dee", "eve"])),
            Arc::new(Float64Array::from(vec![Some(9.5), Some(3.0), None, Some(7.25), Some(1.0)])),
        ],
    )
    .unwrap();
    (schema, batch)
}

fn cities() -> (Schema, RecordBatch) {
    let schema = Schema::new(vec![
        Field::new("person_id", DataType::Int64, false),
        Field::new("city", DataType::Utf8, false),
    ]);
    let batch = RecordBatch::try_new(
        Arc::new(schema.clone()),
        vec![
            Arc::new(Int64Array::from(vec![1, 2, 4])),
            Arc::new(StringArray::from(vec!["Umeå", "Luleå", "Umeå"])),
        ],
    )
    .unwrap();
    (schema, batch)
}

struct Live {
    _dir: tempfile::TempDir,
    engine: Arc<Engine>,
    client: FlightSqlServiceClient<Channel>,
    _server: tokio::task::JoinHandle<()>,
}

async fn bring_up(auth: Auth) -> Live {
    bring_up_with(auth, Duration::from_millis(50)).await
}

async fn bring_up_with(auth: Auth, refresh_after: Duration) -> Live {
    let dir = tempfile::tempdir().unwrap();
    let wh = skade::Warehouse::open(dir.path().join("wh")).await.unwrap();
    let (s, b) = people();
    wh.create_table("people", &s).await.unwrap().append(&[b]).await.unwrap();
    let (s, b) = cities();
    wh.create_table("cities", &s).await.unwrap().append(&[b]).await.unwrap();

    let engine = Arc::new(Engine::with_options(wh, refresh_after, 2).await.unwrap());
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let auth = Arc::new(auth);
    let server = {
        let engine = engine.clone();
        tokio::spawn(async move {
            skade_flight::serve_on(engine, auth, listener, Default::default(), futures::future::pending()).await.unwrap();
        })
    };
    let channel = Channel::from_shared(format!("http://{addr}")).unwrap().connect().await.unwrap();
    Live {
        _dir: dir,
        engine,
        client: FlightSqlServiceClient::new(channel),
        _server: server,
    }
}

async fn fetch(client: &mut FlightSqlServiceClient<Channel>, sql: &str) -> Vec<RecordBatch> {
    let info = client.execute(sql.to_string(), None).await.unwrap();
    let ticket = info.endpoint[0].ticket.clone().unwrap();
    client.do_get(ticket).await.unwrap().try_collect().await.unwrap()
}

/// `fetch`, but the failure is the answer: describe (GetFlightInfo) then
/// execute (DoGet), returning whichever RPC refused.
async fn try_fetch(client: &mut FlightSqlServiceClient<Channel>, sql: &str) -> Result<Vec<RecordBatch>, String> {
    let info = client.execute(sql.to_string(), None).await.map_err(|e| e.to_string())?;
    let ticket = info.endpoint[0].ticket.clone().unwrap();
    client.do_get(ticket).await.map_err(|e| e.to_string())?.try_collect().await.map_err(|e| e.to_string())
}

fn rows(batches: &[RecordBatch]) -> usize {
    batches.iter().map(|b| b.num_rows()).sum()
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn power_bi_shape_over_open_server() {
    let mut live = bring_up(Auth::open()).await;
    let c = &mut live.client;

    // Introspection.
    let info = c.get_db_schemas(CommandGetDbSchemas::default()).await.unwrap();
    let b = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect::<Vec<_>>().await.unwrap();
    assert_eq!(rows(&b), 1, "one namespace (main)");

    let info = c.get_tables(CommandGetTables { include_schema: true, ..Default::default() }).await.unwrap();
    let b = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect::<Vec<_>>().await.unwrap();
    assert_eq!(rows(&b), 2, "cities + people");
    let names = b[0].column_by_name("table_name").unwrap();
    let names = names.as_any().downcast_ref::<StringArray>().unwrap();
    assert_eq!(names.value(0), "cities");
    assert_eq!(names.value(1), "people");

    let info = c.get_sql_info(vec![]).await.unwrap();
    let b = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect::<Vec<_>>().await.unwrap();
    assert!(rows(&b) > 60, "sql_info rows {}", rows(&b));

    let info = c.get_xdbc_type_info(CommandGetXdbcTypeInfo::default()).await.unwrap();
    let b = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect::<Vec<_>>().await.unwrap();
    assert_eq!(rows(&b), 13);

    // Power BI asks per type code: a known code gives its row, ODBC's
    // SQL_ALL_TYPES (0) and a code we do not carry give the whole table —
    // never an empty stream, which the ADBC layer turns into a null and a crash.
    for (dt, want) in [(93, 1), (3, 1), (0, 13), (9, 13), (12345, 13)] {
        let info = c.get_xdbc_type_info(CommandGetXdbcTypeInfo { data_type: Some(dt) }).await.unwrap();
        let b = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect::<Vec<_>>().await.unwrap();
        assert_eq!(rows(&b), want, "GetXdbcTypeInfo(data_type={dt})");
    }

    // The driver's first query.
    let b = fetch(c, r#"SELECT * FROM "people""#).await;
    assert_eq!(rows(&b), 5);
    assert_eq!(b[0].schema().fields().len(), 3);

    // Folded: projection + filter + order + limit.
    let b = fetch(c, "SELECT name, score FROM people WHERE score > 2 ORDER BY score DESC LIMIT 2").await;
    assert_eq!(rows(&b), 2);
    let n = b[0].column(0).as_any().downcast_ref::<StringArray>().unwrap();
    assert_eq!(n.value(0), "ada");
    assert_eq!(n.value(1), "dee");

    // Aggregate.
    let b = fetch(c, "SELECT count(*) AS n, avg(score) AS m FROM people").await;
    let n = b[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap();
    assert_eq!(n.value(0), 5);

    // Join across two Iceberg tables, qualified and bare names both resolve.
    let b = fetch(
        c,
        "SELECT p.name, c.city FROM skade.main.people p JOIN cities c ON c.person_id = p.id ORDER BY p.name",
    )
    .await;
    assert_eq!(rows(&b), 3);

    // Prepared statement path (what ADBC drivers use).
    let mut ps = c.prepare("SELECT id FROM people WHERE id <= 2".to_string(), None).await.unwrap();
    assert_eq!(ps.dataset_schema().unwrap().fields().len(), 1);
    let info = ps.execute().await.unwrap();
    let b = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect::<Vec<_>>().await.unwrap();
    assert_eq!(rows(&b), 2);
    ps.close().await.unwrap();

    // Bad SQL is the caller's fault and says so.
    let err = c.execute("SELEKT 1".to_string(), None).await.unwrap_err();
    assert!(err.to_string().contains("SELEKT") || err.to_string().to_lowercase().contains("sql"), "{err}");

    // Read-only: an update is refused by name.
    let err = c.execute_update("DELETE FROM people".to_string(), None).await.unwrap_err();
    assert!(err.to_string().contains("read-only"), "{err}");

    // A commit after the session was built becomes visible after the refresh window.
    // (Through the engine's own warehouse: redb is single-writer, the ONE live
    // catalog — a second `Warehouse::open` on the same dir is refused by lock.)
    let (s, b) = people();
    live.engine.warehouse().create_table("late", &s).await.unwrap().append(&[b]).await.unwrap();
    tokio::time::sleep(Duration::from_millis(80)).await;
    let b = fetch(c, "SELECT count(*) FROM late").await;
    let n = b[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap();
    assert_eq!(n.value(0), 5);
    assert_eq!(live.engine.tables().await.unwrap().len(), 3);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn basic_handshake_issues_bearer_and_gates_calls() {
    let mut live = bring_up(Auth::bearer("t0k3n").with_basic("pbi", "hunter2")).await;
    let c = &mut live.client;

    // No credentials: refused.
    let err = c.execute("SELECT 1".to_string(), None).await.unwrap_err();
    assert!(err.to_string().contains("authorization"), "{err}");

    // Wrong password: refused at the handshake.
    assert!(c.handshake("pbi", "nope").await.is_err());

    // Right Basic → bearer, and the client replays it. The bearer arrives in the
    // `authorization` RESPONSE HEADER (what every client authenticates with); the
    // handshake deliberately sends no HandshakeResponse message (see
    // `do_handshake`), so the payload arrow-rs returns is empty. Assert the gate:
    // the next call is accepted, and the header carried the real token.
    c.handshake("pbi", "hunter2").await.unwrap();
    assert_eq!(c.token().map(String::as_str), Some("t0k3n"));
    let b = fetch(c, "SELECT count(*) FROM people").await;
    assert_eq!(rows(&b), 1);
}

/// The navigator's key questions: identifier fields answer as the primary
/// key, foreign keys are an EMPTY set with the spec schema — never
/// `UNIMPLEMENTED`.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn keys_metadata_answers_navigator() {
    use skade_flight::arrow_flight::sql::{CommandGetExportedKeys, CommandGetImportedKeys, CommandGetPrimaryKeys};
    let mut live = bring_up(Auth::open()).await;
    // A table WITH identifier fields.
    let (s, b) = people();
    live.engine
        .warehouse()
        .create_table_with_identity("keyed", &s, &["id"])
        .await
        .unwrap()
        .append(&[b])
        .await
        .unwrap();
    live.engine.refresh().await.unwrap();
    let c = &mut live.client;

    let pk = |table: &str| CommandGetPrimaryKeys { catalog: None, db_schema: None, table: table.to_string() };
    let info = c.get_primary_keys(pk("keyed")).await.unwrap();
    let b: Vec<RecordBatch> =
        c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect().await.unwrap();
    assert_eq!(rows(&b), 1);
    let col = b[0].column_by_name("column_name").unwrap();
    assert_eq!(col.as_any().downcast_ref::<StringArray>().unwrap().value(0), "id");

    // No identifier fields → empty, same schema. Unknown table → empty too.
    // (A zero-row result is a schema message and no data batch, so the
    // schema is read off the stream, exactly as a driver does.)
    for t in ["people", "nope"] {
        let info = c.get_primary_keys(pk(t)).await.unwrap();
        let (n, fields) = drain(c, info).await;
        assert_eq!(n, 0, "{t}");
        assert_eq!(fields, 6, "{t}");
    }

    let info = c
        .get_exported_keys(CommandGetExportedKeys { catalog: None, db_schema: None, table: "keyed".into() })
        .await
        .unwrap();
    let (n, fields) = drain(c, info).await;
    assert_eq!((n, fields), (0, 13));
    let info = c
        .get_imported_keys(CommandGetImportedKeys { catalog: None, db_schema: None, table: "keyed".into() })
        .await
        .unwrap();
    let (n, _) = drain(c, info).await;
    assert_eq!(n, 0);
}

/// TLS: a self-signed server cert, the client trusting exactly that cert,
/// Basic handshake over the encrypted channel. What the ODBC driver does by
/// default (`useEncryption=true`).
#[cfg(feature = "tls")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn tls_roundtrip_with_self_signed_cert() {
    use tonic::transport::{Certificate, ClientTlsConfig};
    let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
    let cert_pem = ck.cert.pem();
    let key_pem = ck.signing_key.serialize_pem();

    let dir = tempfile::tempdir().unwrap();
    let wh = skade::Warehouse::open(dir.path().join("wh")).await.unwrap();
    let (s, b) = people();
    wh.create_table("people", &s).await.unwrap().append(&[b]).await.unwrap();
    let engine = Arc::new(Engine::new(wh).await.unwrap());
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    let auth = Arc::new(Auth::bearer("t0k3n").with_basic("pbi", "hunter2"));
    let opts = skade_flight::ServeOptions {
        tls: Some(skade_flight::server::TlsOptions {
            cert_pem: cert_pem.clone().into_bytes(),
            key_pem: key_pem.into_bytes(),
            client_ca_pem: None,
        }),
    };
    let _server = tokio::spawn(skade_flight::serve_on(engine, auth, listener, opts, futures::future::pending()));

    let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem)).domain_name("localhost");
    let channel = Channel::from_shared(format!("https://localhost:{port}"))
        .unwrap()
        .tls_config(tls)
        .unwrap()
        .connect()
        .await
        .unwrap();
    let mut c = FlightSqlServiceClient::new(channel);
    c.handshake("pbi", "hunter2").await.unwrap();
    assert_eq!(c.token().map(String::as_str), Some("t0k3n"));
    let b = fetch(&mut c, "SELECT count(*) FROM people").await;
    assert_eq!(rows(&b), 1);

    // Plaintext against the TLS port must fail, not hang.
    let plain = Channel::from_shared(format!("http://127.0.0.1:{port}")).unwrap().connect().await;
    match plain {
        Ok(ch) => {
            let mut c = FlightSqlServiceClient::new(ch);
            assert!(c.handshake("pbi", "hunter2").await.is_err());
        }
        Err(_) => {}
    }
}

/// Drain a FlightInfo's first endpoint: `(rows, schema field count)`, the
/// schema taken from the stream itself so a zero-row result still has one.
async fn drain(c: &mut FlightSqlServiceClient<Channel>, info: skade_flight::arrow_flight::FlightInfo) -> (usize, usize) {
    let mut stream = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap();
    let mut n = 0;
    while let Some(b) = stream.try_next().await.unwrap() {
        n += b.num_rows();
    }
    let fields = stream.schema().map(|s| s.fields().len()).unwrap_or(0);
    (n, fields)
}

/// **TPC-H Q15's shape — `CREATE VIEW` / query the view / `DROP VIEW` — runs,
/// and the view never reaches the warehouse.**
///
/// MEASURED (t14s, 2026-09-10, the 22-query Flight SQL run): 21/22, Q15 refused
/// because its spec form creates a view. "Read-only" and "cannot hold a
/// session-scoped view" are not the same fact: the warehouse is never written
/// by a view, so refusing it was the engine confusing its own session with the
/// catalog. RED direction: strip the view overlay and the first `execute` fails
/// with iceberg-datafusion's "register table" refusal.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn session_views_serve_q15s_shape_and_never_touch_the_catalog() {
    let mut live = bring_up(Auth::open()).await;
    let c = &mut live.client;

    // 1. CREATE VIEW over an aggregate — Q15's `revenue0`. Goes through the
    //    statement door exactly as an ODBC SQLExecDirect sends it.
    let b = fetch(c, "CREATE VIEW top_scores AS SELECT id AS person, score * 2 AS doubled FROM people WHERE score IS NOT NULL").await;
    assert_eq!(rows(&b), 0, "DDL streams no rows");

    // 2. Query it, unqualified, joined with a real table, with a scalar
    //    subquery over the same view (Q15's `= (SELECT max(...) FROM revenue0)`).
    let b = fetch(
        c,
        "SELECT p.name, v.doubled FROM top_scores v JOIN people p ON p.id = v.person \
         WHERE v.doubled = (SELECT max(doubled) FROM top_scores)",
    )
    .await;
    assert_eq!(rows(&b), 1);
    let n = b[0].column(0).as_any().downcast_ref::<StringArray>().unwrap();
    assert_eq!(n.value(0), "ada");

    // 3. It is a SESSION view: not in the warehouse, not advertised as a table.
    let wh_tables = live.engine.warehouse().table_idents().await.unwrap();
    assert!(!wh_tables.iter().any(|t| t.name() == "top_scores"), "the catalog must not hold it: {wh_tables:?}");
    assert_eq!(live.engine.tables().await.unwrap().len(), 2, "GetTables advertises warehouse tables only");

    // 4. It survives the engine's session rebuild (the 50 ms refresh window
    //    here, 5 s in production) — a client's view must not vanish mid-report.
    tokio::time::sleep(Duration::from_millis(80)).await;
    live.engine.refresh().await.unwrap();
    let b = fetch(c, "SELECT count(*) FROM top_scores").await;
    let n = b[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap();
    assert_eq!(n.value(0), 4);

    // 5. CREATE without OR REPLACE on an existing name is refused — on the
    //    EXECUTION RPC (DoGet): GetFlightInfo only describes, so a driver that
    //    prepares, describes and executes applies a DDL exactly once. OR
    //    REPLACE swaps it and the plan cache forgets the old shape.
    let err = try_fetch(c, "CREATE VIEW top_scores AS SELECT 1 AS x").await.unwrap_err();
    assert!(err.contains("already exists"), "{err}");
    let _ = fetch(c, "CREATE OR REPLACE VIEW top_scores AS SELECT id AS person FROM people").await;
    let b = fetch(c, "SELECT * FROM top_scores").await;
    assert_eq!(b[0].schema().fields().len(), 1, "the replaced shape is what plans now");

    // 6. A view may not shadow a warehouse table.
    let err = try_fetch(c, "CREATE VIEW people AS SELECT 1 AS x").await.unwrap_err();
    assert!(err.contains("table in the warehouse"), "{err}");

    // 7. DROP VIEW, then the name is gone; IF EXISTS is quiet, plain is not.
    let b = fetch(c, "DROP VIEW top_scores").await;
    assert_eq!(rows(&b), 0);
    assert!(try_fetch(c, "SELECT * FROM top_scores").await.is_err());
    let _ = fetch(c, "DROP VIEW IF EXISTS top_scores").await;
    let err = try_fetch(c, "DROP VIEW top_scores").await.unwrap_err();
    assert!(err.contains("no such view"), "{err}");

    // 8. The same DDL through the UPDATE door (a driver that classifies DDL as
    //    an update) is accepted, and reports 0 rows affected.
    let n = c.execute_update("CREATE VIEW via_update AS SELECT id FROM people".to_string(), None).await.unwrap();
    assert_eq!(n, 0);
    let b = fetch(c, "SELECT count(*) FROM via_update").await;
    assert_eq!(b[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap().value(0), 5);

    // 9. Real writes are still refused — and the refusal now says what IS
    //    allowed, so a Q15 author reads the rewrite instead of "forbidden".
    for sql in ["DELETE FROM people", "CREATE TABLE t (x INT)", "INSERT INTO people VALUES (9, 'z', 1.0)"] {
        let err = c.execute_update(sql.to_string(), None).await.unwrap_err();
        let m = err.to_string();
        assert!(m.contains("read-only"), "{m}");
        assert!(m.contains("WITH") && m.contains("CREATE") && m.contains("VIEW"), "the refusal must name the CTE rewrite and session views: {m}");
    }
    // A real DDL through the statement door is refused already at describe
    // time — there is nothing to describe about a statement that will not run.
    let err = c.execute("CREATE TABLE t AS SELECT 1".to_string(), None).await.unwrap_err();
    assert!(err.to_string().contains("read-only"), "{err}");
    match &err {
        skade_flight::arrow_flight::error::FlightError::Tonic(st) => {
            assert_eq!(st.code(), tonic::Code::InvalidArgument, "the statement is out of scope, not the server: {err}")
        }
        other => panic!("expected a status, got {other}"),
    }
}

// ── plan once, signed handles, bound parameters ──────────────────────────

/// **One logical and one physical plan per distinct statement, however many
/// RPCs carry it.** `GetFlightInfo` plans; `DoGet` executes what it planned;
/// a second `GetFlightInfo` + `DoGet` of the same SQL plans nothing.
///
/// Before the ticket carried a signed handle to the cached plan, `DoGet`
/// rebuilt the physical plan on every execute (`DataFrame::execute_stream`
/// runs the optimizer + physical planner each call): two executes of one
/// ticket were two physical plans. RED direction: make `Engine::physical`
/// skip its `OnceCell` and the second assertion reads physical = 2.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_statement_is_planned_once_across_every_rpc_that_carries_it() {
    // A long refresh window: a rebuild drops every plan (they point at the old
    // snapshot), which is right and is not what this measures.
    let mut live = bring_up_with(Auth::open(), Duration::from_secs(600)).await;
    let sql = "SELECT name, score FROM people WHERE score > 2 ORDER BY score DESC";
    let before = live.engine.plan_counts();

    let info = live.client.execute(sql.to_string(), None).await.unwrap();
    let after_info = live.engine.plan_counts();
    assert_eq!(after_info.logical - before.logical, 1, "GetFlightInfo parses + resolves once");
    assert_eq!(after_info.physical - before.physical, 1, "GetFlightInfo optimizes + plans physically once");

    let ticket = info.endpoint[0].ticket.clone().unwrap();
    let b: Vec<RecordBatch> = live.client.do_get(ticket.clone()).await.unwrap().try_collect().await.unwrap();
    assert_eq!(rows(&b), 3);
    let b: Vec<RecordBatch> = live.client.do_get(ticket).await.unwrap().try_collect().await.unwrap();
    assert_eq!(rows(&b), 3, "the same ticket executes again");
    let info2 = live.client.execute(sql.to_string(), None).await.unwrap();
    let (n, _) = drain(&mut live.client, info2).await;
    assert_eq!(n, 3);

    let after_all = live.engine.plan_counts();
    assert_eq!(after_all, after_info, "two DoGets and a second GetFlightInfo planned NOTHING: {after_all:?}");
}

/// **A ticket is a capability, not a string.** Tampered → UNAUTHENTICATED;
/// minted under another key → UNAUTHENTICATED; the old raw-SQL shape →
/// UNAUTHENTICATED (never executed); issued to another principal →
/// UNAUTHENTICATED. RED direction: put the SQL back in the ticket and every
/// arm here executes the query instead.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_ticket_is_signed_bound_to_its_caller_and_never_raw_sql() {
    use skade_flight::arrow_flight::Ticket;
    use skade_flight::arrow_flight::sql::{Any, ProstMessageExt, TicketStatementQuery};
    use skade_flight::ticket::{Signer, TICKET_TTL};
    use skade_flight::Statement;
    use prost::Message;

    let mut live = bring_up(Auth::bearer("t0k3n").with_basic("pbi", "hunter2")).await;
    live.client.handshake("pbi", "hunter2").await.unwrap();
    let sql = "SELECT count(*) FROM people";
    let info = live.client.execute(sql.to_string(), None).await.unwrap();
    let ticket = info.endpoint[0].ticket.clone().unwrap();

    // Opaque: the SQL is not in the ticket in the clear.
    let any = Any::decode(ticket.ticket.clone()).unwrap();
    let tsq: TicketStatementQuery = any.unpack().unwrap().unwrap();
    assert!(
        !String::from_utf8_lossy(&tsq.statement_handle).contains("count(*)"),
        "the handle is an envelope, not the statement"
    );
    // and the untampered one works, twice.
    for _ in 0..2 {
        let b: Vec<RecordBatch> = live.client.do_get(ticket.clone()).await.unwrap().try_collect().await.unwrap();
        assert_eq!(rows(&b), 1);
    }

    let unauth = |err: skade_flight::arrow_flight::error::FlightError| {
        let s = err.to_string();
        assert!(s.contains("Unauthenticated") || s.contains("statement handle refused"), "{s}");
    };

    // Tampered: one byte inside the handle.
    let mut handle = tsq.statement_handle.to_vec();
    let mid = handle.len() / 2;
    handle[mid] ^= 0x01;
    let bad = Ticket::new(TicketStatementQuery { statement_handle: handle.into() }.as_any().encode_to_vec());
    unauth(live.client.do_get(bad).await.unwrap_err());

    // Minted under another key.
    let foreign = Signer::from_secret("not-this-server").issue(Statement::sql(sql), "bearer", TICKET_TTL).unwrap();
    let bad = Ticket::new(TicketStatementQuery { statement_handle: foreign }.as_any().encode_to_vec());
    unauth(live.client.do_get(bad).await.unwrap_err());

    // The old shape: raw SQL as the handle.
    let raw = Ticket::new(TicketStatementQuery { statement_handle: sql.as_bytes().to_vec().into() }.as_any().encode_to_vec());
    unauth(live.client.do_get(raw).await.unwrap_err());

    // Same key, another principal: the server's own signer, a handle issued to
    // someone the caller is not.
    let other = Signer::from_secret("t0k3n").issue(Statement::sql(sql), "mallory", TICKET_TTL).unwrap();
    let bad = Ticket::new(TicketStatementQuery { statement_handle: other }.as_any().encode_to_vec());
    unauth(live.client.do_get(bad).await.unwrap_err());
}

/// **A prepared statement advertises its placeholders and executes what was
/// bound.** `parameter_schema` is `$1: Int64` (inferred from `id <= $1`), a
/// bound 2 returns two rows, a re-bound 4 returns four — two plans, since the
/// optimizer folds the literal. RED direction: leave `parameter_schema` empty
/// and the first assertion fails; ignore the bound batch and both counts read 5.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_prepared_statement_binds_its_parameters() {
    let mut live = bring_up_with(Auth::open(), Duration::from_secs(600)).await;
    let c = &mut live.client;
    let mut ps = c.prepare("SELECT id, name FROM people WHERE id <= $1 ORDER BY id".to_string(), None).await.unwrap();
    let pschema = ps.parameter_schema().unwrap();
    assert_eq!(pschema.fields().len(), 1, "one placeholder: {pschema:?}");
    assert_eq!(pschema.field(0).name(), "$1");
    assert_eq!(pschema.field(0).data_type(), &DataType::Int64, "inferred from `id <= $1`");
    assert_eq!(ps.dataset_schema().unwrap().fields().len(), 2);

    // Unbound: refused by name, not executed as a full scan.
    let err = ps.execute().await.unwrap_err().to_string();
    assert!(err.contains("unbound parameter") && err.contains("$1"), "{err}");

    let bind = |v: i64| {
        RecordBatch::try_new(
            Arc::new(Schema::new(vec![Field::new("$1", DataType::Int64, true)])),
            vec![Arc::new(Int64Array::from(vec![v]))],
        )
        .unwrap()
    };
    let before = live.engine.plan_counts();
    ps.set_parameters(bind(2)).unwrap();
    let info = ps.execute().await.unwrap();
    let b: Vec<RecordBatch> = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect().await.unwrap();
    assert_eq!(rows(&b), 2);
    let ids = b[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap();
    assert_eq!(ids.values(), &[1, 2]);

    ps.set_parameters(bind(4)).unwrap();
    let info = ps.execute().await.unwrap();
    let b: Vec<RecordBatch> = c.do_get(info.endpoint[0].ticket.clone().unwrap()).await.unwrap().try_collect().await.unwrap();
    assert_eq!(rows(&b), 4);
    let after = live.engine.plan_counts();
    assert_eq!(after.physical - before.physical, 2, "two bindings, two plans, no more: {after:?}");
    ps.close().await.unwrap();
}