mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
28c8b5c60f
* feat(cli): add datatable list and run commands * feat(cli): render datatable query results as a table * feat(cli): serve datatables as a postgres-wire endpoint * feat(cli): add 'datatable psql' to launch psql against the proxy * feat(cli): route datatable serve by client-supplied database name * override database list + password option * fix: support extended queries in datatable serve * fix: correct cloud size threshold log and parse CLI descriptions with parens/trailing comma * refactor: extract raw_output envelope encoding into pg_raw_output module --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
3053 lines
125 KiB
Rust
3053 lines
125 KiB
Rust
use std::collections::{HashMap, HashSet};
|
||
use std::net::IpAddr;
|
||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
use anyhow::Context;
|
||
use base64::{engine, Engine as _};
|
||
use chrono::Utc;
|
||
use futures::future::BoxFuture;
|
||
use futures::{FutureExt, StreamExt, TryStreamExt};
|
||
use itertools::Itertools;
|
||
use rust_decimal::{prelude::FromPrimitive, Decimal};
|
||
use serde_json::value::RawValue;
|
||
use serde_json::Map;
|
||
use serde_json::Value;
|
||
use tokio::sync::{Mutex, RwLock};
|
||
use tokio_postgres::Client;
|
||
use tokio_postgres::{types::ToSql, Row};
|
||
use tokio_postgres::{
|
||
types::{FromSql, IsNull, Kind, Type},
|
||
Column,
|
||
};
|
||
use uuid::Uuid;
|
||
use windmill_common::error::to_anyhow;
|
||
use windmill_common::error::{self, Error};
|
||
use windmill_common::worker::{
|
||
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||
};
|
||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
|
||
use windmill_parser::{Arg, Typ};
|
||
use windmill_parser_sql::{
|
||
parse_db_resource, parse_pg_statement_arg_positions, parse_pgsql_sig_with_typed_schema,
|
||
parse_s3_mode, parse_sql_blocks,
|
||
};
|
||
use windmill_queue::{CanceledBy, MiniPulledJob};
|
||
|
||
use crate::agent_workers::get_datatable_resource_from_agent_http;
|
||
use crate::common::{
|
||
build_args_values, get_reserved_variables, s3_mode_args_to_worker_data,
|
||
s3_stream_and_upload_with_logs, sizeof_val, OccupancyMetrics, S3ModeWorkerData,
|
||
};
|
||
use crate::handle_child::run_future_with_polling_update_job_poller;
|
||
use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args;
|
||
use crate::sql_s3_input::fetch_s3object_as_json_text;
|
||
use crate::sql_utils::remove_comments;
|
||
use crate::MAX_RESULT_SIZE;
|
||
use bytes::Buf;
|
||
use lazy_static::lazy_static;
|
||
use windmill_common::client::AuthedClient;
|
||
use windmill_types::s3::S3Object;
|
||
|
||
lazy_static! {
|
||
pub static ref CONNECTION_CACHE: Arc<Mutex<Option<(String, tokio_postgres::Client)>>> =
|
||
Arc::new(Mutex::new(None));
|
||
pub static ref CONNECTION_COUNTER: Arc<RwLock<HashMap<String, u64>>> =
|
||
Arc::new(RwLock::new(HashMap::new()));
|
||
pub static ref LAST_QUERY: AtomicU64 = AtomicU64::new(0);
|
||
pub static ref CACHE_HITS: AtomicU64 = AtomicU64::new(0);
|
||
}
|
||
|
||
pub async fn clear_pg_cache() {
|
||
*CONNECTION_CACHE.lock().await = None;
|
||
CONNECTION_COUNTER.write().await.clear();
|
||
}
|
||
|
||
async fn new_pg_connection(
|
||
database: &PgDatabase,
|
||
_use_iam_auth: bool,
|
||
main_db: Option<&DB>,
|
||
) -> error::Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>)> {
|
||
let (client, connection) = if _use_iam_auth {
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
{
|
||
database.connect_with_iam().await?
|
||
}
|
||
#[cfg(not(all(feature = "enterprise", feature = "private")))]
|
||
{
|
||
return Err(Error::ExecutionErr(
|
||
"IAM RDS authentication requires Windmill Enterprise Edition".to_string(),
|
||
));
|
||
}
|
||
} else {
|
||
database.connect(main_db).await?
|
||
};
|
||
let handle = tokio::spawn(async move {
|
||
if let Err(e) = connection.await {
|
||
let mut mtex = CONNECTION_CACHE.lock().await;
|
||
*mtex = None;
|
||
tracing::error!("connection error: {}", e);
|
||
}
|
||
});
|
||
Ok((client, handle))
|
||
}
|
||
|
||
/// `ToSql` / `FromSql` wrapper for a value whose Postgres wire format is plain
|
||
/// UTF-8 text regardless of the column's *type kind*. Vanilla
|
||
/// `tokio_postgres`'s `ToSql for String` / `FromSql for String` only accepts a
|
||
/// fixed list of base text types (TEXT/VARCHAR/BPCHAR/NAME/UNKNOWN + citext) —
|
||
/// they reject user-defined `Kind::Enum` and `Kind::Domain` even though
|
||
/// enum/domain wire format is just the variant name / the underlying base
|
||
/// type's text. This wrapper plugs that gap on both directions:
|
||
///
|
||
/// - **bind side** (prepare-fallback path): `INSERT INTO t VALUES
|
||
/// ($1::my_enum)` works end-to-end without users needing the
|
||
/// `CAST($1::text AS my_enum)` workaround.
|
||
/// - **read side** (`pg_cell_to_json_value`'s fallback): `SELECT
|
||
/// $1::my_enum`, `SELECT enum_col FROM t`, etc. round-trip into a JSON
|
||
/// string instead of erroring with "cannot convert Option<String> and the
|
||
/// Postgres type `my_enum`".
|
||
#[derive(Debug)]
|
||
struct AnyTextValue(String);
|
||
|
||
fn any_text_accepts(ty: &Type) -> bool {
|
||
// Base text-like types, plus the citext extension type matched by name
|
||
// (it's not in `tokio_postgres::types::Type`'s constants), plus
|
||
// enum/domain kinds. We accept `Kind::Domain` unconditionally — the
|
||
// server is responsible for parsing the bytes and any domain whose
|
||
// base type accepts text on the wire (which is most of them) round-trips
|
||
// naturally.
|
||
matches!(
|
||
*ty,
|
||
Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME | Type::UNKNOWN
|
||
) || ty.name() == "citext"
|
||
|| matches!(ty.kind(), Kind::Enum(_) | Kind::Domain(_))
|
||
}
|
||
|
||
impl ToSql for AnyTextValue {
|
||
fn to_sql(
|
||
&self,
|
||
_ty: &Type,
|
||
out: &mut bytes::BytesMut,
|
||
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
|
||
use bytes::BufMut;
|
||
out.put_slice(self.0.as_bytes());
|
||
Ok(IsNull::No)
|
||
}
|
||
|
||
fn accepts(ty: &Type) -> bool {
|
||
any_text_accepts(ty)
|
||
}
|
||
|
||
tokio_postgres::types::to_sql_checked!();
|
||
}
|
||
|
||
impl<'a> FromSql<'a> for AnyTextValue {
|
||
fn from_sql(
|
||
_ty: &Type,
|
||
raw: &'a [u8],
|
||
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
||
// Postgres' text wire format for enums / domains-over-text / the
|
||
// base text types is the same: UTF-8 bytes of the value.
|
||
Ok(AnyTextValue(std::str::from_utf8(raw)?.to_owned()))
|
||
}
|
||
|
||
fn accepts(ty: &Type) -> bool {
|
||
any_text_accepts(ty)
|
||
}
|
||
}
|
||
|
||
impl ResultFormatState {
|
||
/// Decide whether to actually run the precision-loss check for this cell.
|
||
/// Returns `true` for the first `NUMERIC_PRECISION_CHECK_BUDGET` calls,
|
||
/// then `false` thereafter — and always `false` once the warning has
|
||
/// already been triggered. Cheap on the hot path: an atomic load + an
|
||
/// atomic decrement (Relaxed ordering), no allocation.
|
||
fn should_check_precision(&self) -> bool {
|
||
use std::sync::atomic::Ordering;
|
||
if self.numeric_precision_loss.load(Ordering::Relaxed) {
|
||
return false;
|
||
}
|
||
// `fetch_sub` returns the value BEFORE the decrement. When that's
|
||
// > 0 we had budget left for this cell. After the budget reaches 0
|
||
// the next call would wrap to `u32::MAX-1`; pin it back to 0.
|
||
let prev = self
|
||
.numeric_precision_check_budget
|
||
.fetch_sub(1, Ordering::Relaxed);
|
||
if prev == 0 {
|
||
self.numeric_precision_check_budget
|
||
.store(0, Ordering::Relaxed);
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Emit a single job-log warning if `state.numeric_precision_loss` flipped
|
||
/// during the row iteration. The detection itself is bounded by
|
||
/// `NUMERIC_PRECISION_CHECK_BUDGET` cells, so this only adds a constant-cost
|
||
/// log call at end-of-query.
|
||
async fn warn_on_numeric_precision_loss(
|
||
state: &ResultFormatState,
|
||
job_id: Uuid,
|
||
workspace_id: &str,
|
||
log_conn: &Connection,
|
||
) {
|
||
use std::sync::atomic::Ordering;
|
||
if state.numeric_precision_loss.load(Ordering::Relaxed) {
|
||
windmill_queue::append_logs(
|
||
&job_id,
|
||
workspace_id,
|
||
"warning: at least one `numeric` value in the result lost precision \
|
||
when serialised as a JSON number (the JSON Number format goes through \
|
||
f64, which has ~15-17 significant digits). To preserve full precision, \
|
||
cast the column to text in your SQL — e.g. `SELECT col::text` — and \
|
||
parse the string client-side with a Decimal library.\n",
|
||
log_conn,
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
/// Emit a one-shot warning naming each declared arg the user didn't supply a
|
||
/// value for. PG executor binds these as NULL for back-compat — without a
|
||
/// warning, a misspelled arg key in the args object silently produces a row
|
||
/// of NULLs, which is a notoriously hard DX bug to track down.
|
||
async fn warn_on_missing_args(
|
||
missing: &[String],
|
||
job_id: Uuid,
|
||
workspace_id: &str,
|
||
log_conn: &Connection,
|
||
) {
|
||
if missing.is_empty() {
|
||
return;
|
||
}
|
||
let names = missing
|
||
.iter()
|
||
.map(|n| format!("`{n}`"))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
windmill_queue::append_logs(
|
||
&job_id,
|
||
workspace_id,
|
||
format!(
|
||
"warning: argument(s) {names} declared in the query but not provided in the \
|
||
args object — bound as NULL. Add the value(s) to the job args, declare a \
|
||
default in the SQL (`-- $1 name (type) = default`), or remove the \
|
||
declaration if the arg isn't used.\n"
|
||
),
|
||
log_conn,
|
||
)
|
||
.await;
|
||
}
|
||
|
||
/// Short stable label for the JSON value's variant — used in error messages
|
||
/// so users can see *what kind of value* hit a binding error.
|
||
fn json_value_kind(v: &Value) -> &'static str {
|
||
match v {
|
||
Value::Null => "null",
|
||
Value::Bool(_) => "bool",
|
||
Value::Number(_) => "number",
|
||
Value::String(_) => "string",
|
||
Value::Array(_) => "array",
|
||
Value::Object(_) => "object",
|
||
}
|
||
}
|
||
|
||
/// rust-postgres reports parameter encoding failures as
|
||
/// `error serializing parameter N: <inner>` with N being a 0-based index.
|
||
/// Pull N out so we can attach our own metadata.
|
||
fn parse_param_index_from_err_msg(msg: &str) -> Option<usize> {
|
||
msg.strip_prefix("error serializing parameter ")
|
||
.and_then(|rest| rest.split(':').next())
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
}
|
||
|
||
/// Replace a rust-postgres encoder error with one that names the offending
|
||
/// arg, its JSON value kind, and the Postgres type we asserted, plus a hint
|
||
/// about how to fix it. Other errors are passed through unchanged.
|
||
fn wrap_param_encoding_error(
|
||
err: tokio_postgres::Error,
|
||
param_meta: &[(String, &'static str)],
|
||
param_types: &[Type],
|
||
) -> Error {
|
||
let msg = err.to_string();
|
||
if let Some(idx) = parse_param_index_from_err_msg(&msg) {
|
||
if let (Some((name, kind)), Some(t)) = (param_meta.get(idx), param_types.get(idx)) {
|
||
return Error::ExecutionErr(format!(
|
||
"Cannot bind arg `{name}` (JSON {kind}) as Postgres type `{t}` ({err}). \
|
||
Try adding an explicit cast in the SQL — e.g. `${pos}::<column_type>` \
|
||
or `CAST(${pos} AS <column_type>)` — or declare the type via \
|
||
`-- ${pos} {name} (<column_type>)`.",
|
||
pos = idx + 1,
|
||
));
|
||
}
|
||
}
|
||
to_anyhow(err).into()
|
||
}
|
||
|
||
fn otyp_to_pg_type(otyp: &str) -> error::Result<Type> {
|
||
let base = otyp.trim_end_matches("[]");
|
||
let is_array = otyp.ends_with("[]");
|
||
|
||
let (scalar, array) = match base {
|
||
"bool" | "boolean" => (Type::BOOL, Type::BOOL_ARRAY),
|
||
"char" | "character" => (Type::CHAR, Type::CHAR_ARRAY),
|
||
"smallint" | "smallserial" | "int2" | "serial2" => (Type::INT2, Type::INT2_ARRAY),
|
||
"int" | "integer" | "int4" | "serial" => (Type::INT4, Type::INT4_ARRAY),
|
||
"bigint" | "bigserial" | "int8" | "serial8" => (Type::INT8, Type::INT8_ARRAY),
|
||
"real" | "float4" => (Type::FLOAT4, Type::FLOAT4_ARRAY),
|
||
"double" | "double precision" | "float8" => (Type::FLOAT8, Type::FLOAT8_ARRAY),
|
||
"numeric" | "decimal" => (Type::NUMERIC, Type::NUMERIC_ARRAY),
|
||
"text" => (Type::TEXT, Type::TEXT_ARRAY),
|
||
"varchar" | "character varying" => (Type::VARCHAR, Type::VARCHAR_ARRAY),
|
||
"uuid" => (Type::UUID, Type::UUID_ARRAY),
|
||
"date" => (Type::DATE, Type::DATE_ARRAY),
|
||
"time" => (Type::TIME, Type::TIME_ARRAY),
|
||
"timetz" => (Type::TIMETZ, Type::TIMETZ_ARRAY),
|
||
"timestamp" => (Type::TIMESTAMP, Type::TIMESTAMP_ARRAY),
|
||
"timestamptz" => (Type::TIMESTAMPTZ, Type::TIMESTAMPTZ_ARRAY),
|
||
"json" => (Type::JSON, Type::JSON_ARRAY),
|
||
"jsonb" => (Type::JSONB, Type::JSONB_ARRAY),
|
||
"bytea" => (Type::BYTEA, Type::BYTEA_ARRAY),
|
||
"oid" => (Type::OID, Type::OID_ARRAY),
|
||
_ => {
|
||
return Err(Error::ExecutionErr(format!(
|
||
"Unsupported PostgreSQL type for typed schema: {}",
|
||
otyp
|
||
)))
|
||
}
|
||
};
|
||
|
||
Ok(if is_array { array } else { scalar })
|
||
}
|
||
|
||
fn do_postgresql_inner<'a>(
|
||
mut query: String,
|
||
param_idx_to_arg_and_value: &HashMap<i32, (&Arg, Option<&Value>)>,
|
||
client: &'a Client,
|
||
column_order: Option<&'a mut Option<Vec<String>>>,
|
||
siz: &'a AtomicUsize,
|
||
skip_collect: bool,
|
||
first_row_only: bool,
|
||
s3: Option<S3ModeWorkerData>,
|
||
job_id: Uuid,
|
||
workspace_id: &'a str,
|
||
log_conn: &'a Connection,
|
||
raw_output: bool,
|
||
) -> error::Result<BoxFuture<'a, error::Result<Vec<Box<RawValue>>>>> {
|
||
let mut query_params = vec![];
|
||
let mut param_types: Vec<Type> = vec![];
|
||
// Per-param metadata used to wrap rust-postgres `error serializing
|
||
// parameter N` errors with actionable context (arg name, JSON value kind,
|
||
// asserted Postgres type) — see error wrapping at the dispatch site.
|
||
let mut param_meta: Vec<(String, &'static str)> = vec![];
|
||
// Track whether every arg has a resolvable Postgres type. We need *both*
|
||
// the parser-supplied otyp to be in `otyp_to_pg_type`'s map (so the arg
|
||
// isn't a custom enum / extension type) *and* convert_val to produce a
|
||
// (binding, type) pair that the encoder can actually serialize. If both
|
||
// hold for every arg, we send the query as an unnamed prepared statement
|
||
// (query_typed_raw) — see the dispatch comment below. Otherwise we fall
|
||
// back to prepare + query_raw and let the server resolve the parameter
|
||
// types from the SQL context.
|
||
let mut all_types_resolved = true;
|
||
|
||
// Single tokenizer pass — derive both the index set (for the param
|
||
// dispatch loop below) and the byte ranges (for sparse renumbering) from
|
||
// one walk over the SQL. Positions skip occurrences inside string
|
||
// literals, comments, and dollar-quoted blocks, so the rewrite below
|
||
// doesn't mangle a query like `SELECT 'price: $5' AS lbl, $5 FROM t`.
|
||
let positions = parse_pg_statement_arg_positions(&query);
|
||
let arg_indices: HashSet<i32> = positions.iter().map(|(i, _)| *i).collect();
|
||
|
||
// Renumber sparse positional placeholders (e.g. $5, $50 → $1, $2) by
|
||
// byte position, walking back-to-front so earlier positions don't shift.
|
||
let renumber_mapping: HashMap<i32, usize> = arg_indices
|
||
.iter()
|
||
.sorted()
|
||
.enumerate()
|
||
.map(|(i, oidx)| (*oidx, i + 1))
|
||
.collect();
|
||
if renumber_mapping
|
||
.iter()
|
||
.any(|(oidx, new_i)| *oidx as usize != *new_i)
|
||
{
|
||
let mut positions = positions.clone();
|
||
positions.sort_by_key(|(_, range)| std::cmp::Reverse(range.start));
|
||
for (oidx, range) in positions {
|
||
if let Some(new_i) = renumber_mapping.get(&oidx) {
|
||
if oidx as usize != *new_i {
|
||
query.replace_range(range, &new_i.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Args the user didn't supply a value for — if their declaration doesn't
|
||
// carry a default, we still bind NULL (back-compat with how the PG
|
||
// executor has worked for years), but we collect them here to emit a
|
||
// single one-shot warning to the job logs after query execution so a typo
|
||
// / missing key doesn't silently turn into a row of NULLs.
|
||
let mut missing_args: Vec<String> = Vec::new();
|
||
// Stash declaration-default values so we can borrow them by reference
|
||
// alongside user-supplied values — both paths feed `convert_val(&Value)`.
|
||
let mut default_values: HashMap<i32, serde_json::Value> = HashMap::new();
|
||
|
||
for oidx in arg_indices.iter().sorted() {
|
||
if let Some((arg, value)) = param_idx_to_arg_and_value.get(&oidx) {
|
||
// Resolve the value: explicit user value > declaration default > NULL.
|
||
let value: &serde_json::Value = match (value, arg.default.as_ref()) {
|
||
(Some(v), _) => *v,
|
||
(None, Some(d)) => default_values.entry(*oidx).or_insert_with(|| d.clone()),
|
||
(None, None) => {
|
||
if !arg.has_default && !missing_args.contains(&arg.name) {
|
||
missing_args.push(arg.name.clone());
|
||
}
|
||
&serde_json::Value::Null
|
||
}
|
||
};
|
||
let arg_t = arg
|
||
.otyp
|
||
.as_ref()
|
||
.ok_or_else(|| anyhow::anyhow!("Missing otyp for pg arg"))?;
|
||
let typ = &arg.typ;
|
||
let (param, natural_type) = convert_val(value, arg_t, typ, arg.otyp_inferred)?;
|
||
query_params.push(param);
|
||
param_meta.push((arg.name.clone(), json_value_kind(value)));
|
||
if all_types_resolved {
|
||
if otyp_to_pg_type(arg_t).is_ok() {
|
||
// The Type comes from convert_val (paired with the binding's
|
||
// concrete Rust type) rather than from `otyp_to_pg_type(arg_t)`
|
||
// — this prevents the parser-default "text" otyp from
|
||
// forcing an assertion that the encoder can't satisfy
|
||
// (e.g. Value::Bool with parser-defaulted text → Type::TEXT
|
||
// on a Box<bool>).
|
||
param_types.push(natural_type);
|
||
} else {
|
||
all_types_resolved = false;
|
||
param_types.clear();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let result_f = async move {
|
||
let mut res: Vec<Box<serde_json::value::RawValue>> = vec![];
|
||
|
||
// Always prefer query_typed_raw (unnamed prepared statement). It is sent as
|
||
// a single Parse+Bind+Execute+Sync round-trip, so it survives transaction-mode
|
||
// connection poolers (PgBouncer/Supabase pooler/RDS Proxy) where named
|
||
// statements ("s0", "s1", ...) can be reported missing because the prepare
|
||
// and the execute land on different backend connections. Fall back to
|
||
// prepare + query_raw only when an arg has a type unsupported by
|
||
// otyp_to_pg_type (e.g. custom enum, geometry, …) — in that case we lose
|
||
// pooler safety, but the query at least runs against a direct connection.
|
||
let rows = if all_types_resolved {
|
||
let typed_params = query_params
|
||
.iter()
|
||
.zip(param_types.iter())
|
||
.map(|(p, t)| (&**p as &(dyn ToSql + Sync), t.clone()));
|
||
match client.query_typed_raw(&query, typed_params).await {
|
||
Ok(rows) => rows,
|
||
Err(e) => {
|
||
return Err(wrap_param_encoding_error(e, ¶m_meta, ¶m_types));
|
||
}
|
||
}
|
||
} else {
|
||
let query_params = query_params
|
||
.iter()
|
||
.map(|p| &**p as &(dyn ToSql + Sync))
|
||
.collect_vec();
|
||
let statement = client.prepare(&query).await.map_err(to_anyhow)?;
|
||
client
|
||
.query_raw(&statement, query_params)
|
||
.await
|
||
.map_err(to_anyhow)?
|
||
};
|
||
|
||
// One state object per query — `pg_cell_to_json_value_with_state`
|
||
// flips `numeric_precision_loss` once if any `numeric` cell can't
|
||
// round-trip through f64. We emit a single warning to the job log
|
||
// after the iteration finishes, instead of error-by-error or per
|
||
// row, and the per-row check short-circuits on the flag so the cost
|
||
// is one branch after the first lossy value.
|
||
let format_state = ResultFormatState::default();
|
||
|
||
if skip_collect {
|
||
futures::pin_mut!(rows);
|
||
while rows.try_next().await.map_err(to_anyhow)?.is_some() {}
|
||
} else if let Some(ref s3) = s3 {
|
||
let format_state_ref = &format_state;
|
||
let rows_stream = rows.map_err(to_anyhow).map(move |row_result| {
|
||
row_result.and_then(|row| {
|
||
postgres_row_to_json_value_with_state(row, format_state_ref).map_err(to_anyhow)
|
||
})
|
||
});
|
||
|
||
s3_stream_and_upload_with_logs(
|
||
"PostgreSQL",
|
||
rows_stream.boxed(),
|
||
s3,
|
||
job_id,
|
||
workspace_id,
|
||
log_conn,
|
||
)
|
||
.await?;
|
||
|
||
warn_on_numeric_precision_loss(&format_state, job_id, workspace_id, log_conn).await;
|
||
warn_on_missing_args(&missing_args, job_id, workspace_id, log_conn).await;
|
||
|
||
return Ok(vec![to_raw_value(&s3.to_return_s3_obj())]);
|
||
} else {
|
||
let rows = if first_row_only {
|
||
rows.take(1).boxed()
|
||
} else {
|
||
rows.boxed()
|
||
};
|
||
|
||
let rows = rows.try_collect::<Vec<Row>>().await.map_err(to_anyhow)?;
|
||
|
||
if let Some(column_order) = column_order {
|
||
*column_order = Some(
|
||
rows.first()
|
||
.map(|x| {
|
||
x.columns()
|
||
.iter()
|
||
.map(|x| x.name().to_string())
|
||
.collect::<Vec<String>>()
|
||
})
|
||
.unwrap_or_default(),
|
||
);
|
||
}
|
||
|
||
if raw_output {
|
||
let envelope = crate::pg_raw_output::build_envelope(rows, &format_state, siz)?;
|
||
res.push(to_raw_value(&envelope));
|
||
} else {
|
||
for row in rows.into_iter() {
|
||
let r = postgres_row_to_json_value_with_state(row, &format_state);
|
||
if let Ok(v) = r.as_ref() {
|
||
let size = sizeof_val(v);
|
||
siz.fetch_add(size, Ordering::Relaxed);
|
||
}
|
||
if *CLOUD_HOSTED {
|
||
let siz = siz.load(Ordering::Relaxed);
|
||
if siz > MAX_RESULT_SIZE * 4 {
|
||
return Err(Error::ExecutionErr(format!(
|
||
"Query result too large for cloud (size = {} > {})",
|
||
siz,
|
||
MAX_RESULT_SIZE * 4,
|
||
)));
|
||
}
|
||
}
|
||
if let Ok(v) = r {
|
||
res.push(to_raw_value(&v));
|
||
} else {
|
||
return Err(to_anyhow(r.err().unwrap()).into());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
warn_on_numeric_precision_loss(&format_state, job_id, workspace_id, log_conn).await;
|
||
warn_on_missing_args(&missing_args, job_id, workspace_id, log_conn).await;
|
||
|
||
Ok(res)
|
||
};
|
||
|
||
Ok(result_f.boxed())
|
||
}
|
||
|
||
pub async fn do_postgresql(
|
||
job: &MiniPulledJob,
|
||
client: &AuthedClient,
|
||
query: &str,
|
||
conn: &Connection,
|
||
mem_peak: &mut i32,
|
||
canceled_by: &mut Option<CanceledBy>,
|
||
worker_name: &str,
|
||
column_order: &mut Option<Vec<String>>,
|
||
occupancy_metrics: &mut OccupancyMetrics,
|
||
parent_runnable_path: Option<String>,
|
||
run_inline: bool,
|
||
) -> error::Result<Box<RawValue>> {
|
||
let mut pg_args = build_args_values(job, client, conn).await?;
|
||
|
||
let inline_db_res_path = parse_db_resource(&query);
|
||
|
||
let s3 = parse_s3_mode(&query)?.map(|s3| s3_mode_args_to_worker_data(s3, client.clone(), job));
|
||
|
||
let db_arg = if let Some(inline_db_res_path) = inline_db_res_path {
|
||
Some(
|
||
client
|
||
.get_resource_value_interpolated::<serde_json::Value>(
|
||
&inline_db_res_path,
|
||
Some(job.id.to_string()),
|
||
)
|
||
.await?,
|
||
)
|
||
} else {
|
||
match pg_args.get("database").cloned() {
|
||
Some(Value::String(db_str)) if db_str.starts_with("datatable://") => {
|
||
let db_str = db_str.trim_start_matches("datatable://");
|
||
Some(match conn {
|
||
Connection::Http(client) => {
|
||
get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id)
|
||
.await?
|
||
}
|
||
Connection::Sql(db) => {
|
||
get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str)
|
||
.await?
|
||
}
|
||
})
|
||
}
|
||
database => database,
|
||
}
|
||
};
|
||
|
||
let database = if let Some(db) = db_arg {
|
||
serde_json::from_value::<PgDatabase>(db.clone())
|
||
.map_err(|e| Error::ExecutionErr(e.to_string()))?
|
||
} else {
|
||
return Err(Error::BadRequest("Missing database argument".to_string()));
|
||
};
|
||
|
||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||
let collection_strategy = if annotations.raw_output || annotations.return_last_result {
|
||
// raw_output emits a single envelope from the last statement, so the
|
||
// multi-statement collection modes don't apply.
|
||
SqlResultCollectionStrategy::LastStatementAllRows
|
||
} else {
|
||
annotations.result_collection
|
||
};
|
||
|
||
let use_iam_auth = database.use_iam_auth == Some(true);
|
||
|
||
// Include use_iam_auth in cache key to distinguish IAM vs non-IAM connections to the same host.
|
||
// The cache key is static (doesn't include the token), which is correct because PostgreSQL
|
||
// connections remain valid after initial auth — fresh tokens are generated on cache miss.
|
||
let database_string = if use_iam_auth {
|
||
format!("{}?iam=true", database.to_uri())
|
||
} else {
|
||
database.to_uri()
|
||
};
|
||
let database_string_clone = database_string.clone();
|
||
|
||
let cached_client;
|
||
let new_client;
|
||
if !*CLOUD_HOSTED {
|
||
let mut guard = CONNECTION_CACHE.try_lock().ok();
|
||
increment_connection_counter(&database_string).await;
|
||
|
||
if guard
|
||
.as_ref()
|
||
.is_some_and(|x| x.as_ref().is_some_and(|y| y.0 == database_string))
|
||
{
|
||
// Probe the cached connection with a curated session reset before
|
||
// reusing it. Each statement targets a specific class of state:
|
||
//
|
||
// RESET ALL — GUC parameters (search_path,
|
||
// application_name, statement_
|
||
// timeout, transaction_*…). Note
|
||
// that this does NOT reset SET
|
||
// ROLE or SET SESSION
|
||
// AUTHORIZATION (security!).
|
||
// RESET SESSION AUTHORIZATION — undoes both `SET SESSION
|
||
// AUTHORIZATION` and `SET ROLE`,
|
||
// restoring the connecting user.
|
||
// Without this a previous job
|
||
// leaving an elevated role
|
||
// active would silently leak
|
||
// permissions into the next.
|
||
// UNLISTEN * — drops LISTEN registrations.
|
||
// CLOSE ALL — closes open cursors.
|
||
// pg_advisory_unlock_all() — releases any session-scoped
|
||
// advisory locks. Without this
|
||
// a job that called
|
||
// pg_advisory_lock and exited
|
||
// without unlocking would block
|
||
// later jobs holding the same
|
||
// key (DISCARD ALL covered this
|
||
// too).
|
||
//
|
||
// We deliberately do NOT use `DISCARD ALL`. DISCARD includes
|
||
// `DEALLOCATE ALL`, which deallocates *all* prepared statements
|
||
// server-side — including the typeinfo statements that
|
||
// tokio_postgres caches per-Client to resolve custom enum/domain
|
||
// Oids. After DISCARD, tokio_postgres still holds Statement
|
||
// objects whose names the server has forgotten, so the next
|
||
// custom-type query fails with `prepared statement "sN" does not
|
||
// exist`. The trade-off: temp tables and user-PREPARE statements
|
||
// may persist across cached-connection reuse (rare in datatable /
|
||
// script workloads).
|
||
//
|
||
// Doubles as a liveness probe — if the connection is broken any
|
||
// statement in the chain fails and we replace it.
|
||
let probe_client = &guard.as_ref().unwrap().as_ref().unwrap().1;
|
||
if probe_client
|
||
.batch_execute(
|
||
"RESET ALL; \
|
||
RESET SESSION AUTHORIZATION; \
|
||
UNLISTEN *; \
|
||
CLOSE ALL; \
|
||
SELECT pg_advisory_unlock_all();",
|
||
)
|
||
.await
|
||
.is_ok()
|
||
{
|
||
tracing::info!("Using cached connection");
|
||
CACHE_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
LAST_QUERY.store(
|
||
chrono::Utc::now().timestamp().try_into().unwrap_or(0),
|
||
std::sync::atomic::Ordering::Relaxed,
|
||
);
|
||
cached_client = guard;
|
||
new_client = None;
|
||
} else {
|
||
tracing::info!("Cached connection is stale, creating new one");
|
||
if let Some(ref mut g) = guard {
|
||
**g = None;
|
||
}
|
||
drop(guard);
|
||
cached_client = None;
|
||
new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?);
|
||
}
|
||
} else {
|
||
// Release the lock before connecting so the post-query caching
|
||
// code can re-acquire it.
|
||
drop(guard);
|
||
cached_client = None;
|
||
new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?);
|
||
}
|
||
} else {
|
||
cached_client = None;
|
||
new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?);
|
||
}
|
||
|
||
let (mut sig, _) = parse_pgsql_sig_with_typed_schema(&query)
|
||
.map_err(|x| Error::ExecutionErr(x.to_string()))?;
|
||
|
||
// Materialize any `(s3object)` args into JSON text and rebind them as `jsonb` so
|
||
// `otyp_to_pg_type` picks the right binding. Must run before the param map is
|
||
// built below.
|
||
materialize_s3object_args(&mut sig.args, &mut pg_args, client, &job.workspace_id).await?;
|
||
|
||
let reserved_variables =
|
||
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
||
|
||
let (query, _) =
|
||
&sanitize_and_interpolate_unsafe_sql_args(query, &sig.args, &pg_args, &reserved_variables)?;
|
||
|
||
let queries = parse_sql_blocks(query, true);
|
||
|
||
let (client, handle) = if let Some((client, handle)) = new_client.as_ref() {
|
||
(client, Some(handle))
|
||
} else {
|
||
let (_, client) = cached_client.as_ref().unwrap().as_ref().unwrap();
|
||
(client, None)
|
||
};
|
||
|
||
let param_idx_to_arg_and_value = sig
|
||
.args
|
||
.iter()
|
||
.filter_map(|x| x.oidx.map(|oidx| (oidx, (x, pg_args.get(&x.name)))))
|
||
.collect::<HashMap<_, _>>();
|
||
|
||
let size = AtomicUsize::new(0);
|
||
let size_ref = &size;
|
||
let result_f = async move {
|
||
let mut results = vec![];
|
||
// Session reset (DISCARD ALL) is now handled eagerly when validating
|
||
// the cached connection — no per-query reset needed here.
|
||
|
||
for (i, query) in queries.iter().enumerate() {
|
||
if annotations.prepare {
|
||
let query = remove_comments(query);
|
||
// Used by the data table typechecker to set default schemas
|
||
if query.starts_with("SET search_path") || query.starts_with("RESET search_path") {
|
||
let _ = client.execute(&query.to_string(), &[]).await;
|
||
continue;
|
||
}
|
||
let prepared = client.prepare(&query).await;
|
||
let prepared = match prepared {
|
||
Ok(prepared) => {
|
||
let columns: Option<Vec<PrepareQueryColumnInfo>> = Some(
|
||
prepared
|
||
.columns()
|
||
.iter()
|
||
.map(|col| PrepareQueryColumnInfo {
|
||
name: col.name().to_string(),
|
||
type_name: col.type_().name().to_string(),
|
||
})
|
||
.collect(),
|
||
);
|
||
PrepareQueryResult { columns, error: None }
|
||
}
|
||
Err(e) => PrepareQueryResult { columns: None, error: Some(e.to_string()) },
|
||
};
|
||
results.push(vec![to_raw_value(&prepared)]);
|
||
continue;
|
||
}
|
||
|
||
let skip_collect = collection_strategy.collect_last_statement_only(queries.len())
|
||
&& i < queries.len() - 1;
|
||
let is_last = i == queries.len() - 1;
|
||
let result = do_postgresql_inner(
|
||
query.to_string(),
|
||
¶m_idx_to_arg_and_value,
|
||
client,
|
||
if is_last
|
||
&& s3.is_none()
|
||
&& collection_strategy.collect_last_statement_only(queries.len())
|
||
&& !collection_strategy.collect_scalar()
|
||
{
|
||
Some(column_order)
|
||
} else {
|
||
None
|
||
},
|
||
size_ref,
|
||
skip_collect,
|
||
collection_strategy.collect_first_row_only(),
|
||
s3.clone(),
|
||
job.id,
|
||
&job.workspace_id,
|
||
conn,
|
||
annotations.raw_output && is_last && !skip_collect,
|
||
)?
|
||
.await?;
|
||
results.push(result);
|
||
}
|
||
|
||
if annotations.raw_output {
|
||
// The raw_output envelope already aggregates the last statement's
|
||
// result; skip the collection_strategy reshape that wraps rows in
|
||
// a JSON array.
|
||
Ok(crate::pg_raw_output::extract_envelope_or_empty(results))
|
||
} else {
|
||
collection_strategy.collect(results)
|
||
}
|
||
};
|
||
|
||
let result = if run_inline {
|
||
result_f.await?
|
||
} else {
|
||
run_future_with_polling_update_job_poller(
|
||
job.id,
|
||
job.timeout,
|
||
conn,
|
||
mem_peak,
|
||
canceled_by,
|
||
result_f,
|
||
worker_name,
|
||
&job.workspace_id,
|
||
&mut Some(occupancy_metrics),
|
||
Box::pin(futures::stream::once(async { 0 })),
|
||
)
|
||
.await?
|
||
};
|
||
|
||
// Release the cache lock now that we have the result — allows the
|
||
// post-query caching code below to re-acquire it if needed.
|
||
drop(cached_client);
|
||
|
||
*mem_peak = size.load(Ordering::Relaxed) as i32;
|
||
|
||
if let Some(handle) = handle {
|
||
if !*CLOUD_HOSTED {
|
||
if let Ok(mut mtex) = CONNECTION_CACHE.try_lock() {
|
||
if mtex.as_ref().is_none_or(|x| x.0 != database_string) {
|
||
let abort_handler = handle.abort_handle();
|
||
|
||
let mut cache_new_con = false;
|
||
if let Some(new_client) = new_client {
|
||
cache_new_con = is_most_used_conn(&database_string).await;
|
||
if cache_new_con {
|
||
*mtex = Some((database_string, new_client.0));
|
||
} else {
|
||
new_client.1.abort();
|
||
}
|
||
} else {
|
||
handle.abort();
|
||
}
|
||
|
||
if cache_new_con {
|
||
LAST_QUERY.store(
|
||
chrono::Utc::now().timestamp().try_into().unwrap_or(0),
|
||
std::sync::atomic::Ordering::Relaxed,
|
||
);
|
||
tokio::spawn(async move {
|
||
loop {
|
||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||
let last_query =
|
||
LAST_QUERY.load(std::sync::atomic::Ordering::Relaxed);
|
||
let now = chrono::Utc::now().timestamp().try_into().unwrap_or(0);
|
||
|
||
//we cache connection for 5 minutes at most
|
||
if last_query + 60 * 1 < now {
|
||
// tracing::error!("Closing cache connection due to inactivity");
|
||
tracing::info!(
|
||
"Closing cache pg executor connection due to inactivity"
|
||
);
|
||
break;
|
||
}
|
||
let mtex = CONNECTION_CACHE.lock().await;
|
||
if mtex.is_none() {
|
||
// connection is not in the mutex anymore
|
||
break;
|
||
} else if let Some(mtex) = mtex.as_ref() {
|
||
if mtex.0.as_str() != &database_string_clone {
|
||
// connection is not the latest one
|
||
break;
|
||
}
|
||
}
|
||
|
||
tracing::debug!(
|
||
"Keeping cached pg executor connection alive due to activity"
|
||
)
|
||
}
|
||
let mut mtex = CONNECTION_CACHE.lock().await;
|
||
*mtex = None;
|
||
abort_handler.abort();
|
||
});
|
||
}
|
||
} else {
|
||
handle.abort();
|
||
}
|
||
} else {
|
||
handle.abort();
|
||
}
|
||
} else {
|
||
handle.abort();
|
||
}
|
||
}
|
||
*mem_peak = (result.get().len() / 1000) as i32;
|
||
// And then check that we got back the same string we sent over.
|
||
return Ok(result);
|
||
}
|
||
|
||
async fn is_most_used_conn(database_string: &str) -> bool {
|
||
let counter_map = CONNECTION_COUNTER.read().await;
|
||
let current_count = counter_map.get(database_string).copied().unwrap_or(0);
|
||
let max_count = counter_map.values().copied().max().unwrap_or(0);
|
||
current_count >= max_count
|
||
}
|
||
|
||
async fn increment_connection_counter(database_string: &str) {
|
||
let mut counter_map = CONNECTION_COUNTER.write().await;
|
||
*counter_map.entry(database_string.to_string()).or_insert(0) += 1;
|
||
}
|
||
|
||
/// For each `(s3object)` arg in `sig_args`: download the referenced file, decode it
|
||
/// to JSON text, then rewrite the arg to bind as `jsonb`. Mutates `args_map` in place
|
||
/// so the existing bind path picks up the materialized payload.
|
||
async fn materialize_s3object_args(
|
||
sig_args: &mut [Arg],
|
||
args_map: &mut HashMap<String, Value>,
|
||
client: &AuthedClient,
|
||
workspace_id: &str,
|
||
) -> error::Result<()> {
|
||
for arg in sig_args.iter_mut() {
|
||
if arg.otyp.as_deref() != Some("s3object") {
|
||
continue;
|
||
}
|
||
let raw = args_map.remove(&arg.name).unwrap_or(Value::Null);
|
||
if matches!(raw, Value::Null) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Missing S3Object value for arg `{}`",
|
||
arg.name
|
||
)));
|
||
}
|
||
let s3_obj: S3Object = serde_json::from_value(raw).map_err(|e| {
|
||
Error::ExecutionErr(format!("Invalid S3Object for arg `{}`: {e}", arg.name))
|
||
})?;
|
||
let json_text = fetch_s3object_as_json_text(client, workspace_id, &s3_obj)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::ExecutionErr(format!(
|
||
"Failed to fetch S3 object for arg `{}`: {e}",
|
||
arg.name
|
||
))
|
||
})?;
|
||
// Parse to a Value so `convert_val`'s Array/Object → JSONB branches bind it
|
||
// correctly. A bare String would mismatch the JSONB param type.
|
||
let parsed: Value = serde_json::from_str(&json_text).map_err(|e| {
|
||
Error::ExecutionErr(format!(
|
||
"S3 object for arg `{}` is not valid JSON after decoding: {e}",
|
||
arg.name
|
||
))
|
||
})?;
|
||
args_map.insert(arg.name.clone(), parsed);
|
||
arg.otyp = Some("jsonb".to_string());
|
||
arg.typ = Typ::Object(windmill_parser::ObjectType::new(None, Some(vec![])));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Parse a date string in formats produced by chrono's Display or JS frontends.
|
||
fn parse_naive_date(s: &str) -> Result<chrono::NaiveDate, chrono::ParseError> {
|
||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||
.or_else(|_| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ"))
|
||
.or_else(|_| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ"))
|
||
.or_else(|_| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
|
||
}
|
||
|
||
/// Parse a time string in formats produced by chrono's Display or JS frontends.
|
||
fn parse_naive_time(s: &str) -> Result<chrono::NaiveTime, chrono::ParseError> {
|
||
chrono::NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
|
||
.or_else(|_| chrono::NaiveTime::parse_from_str(s, "%H:%M:%S"))
|
||
.or_else(|_| chrono::NaiveTime::parse_from_str(s, "%H:%M"))
|
||
.or_else(|_| {
|
||
// Handle full datetime strings by extracting the time part
|
||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ")
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ"))
|
||
.map(|dt| dt.time())
|
||
})
|
||
}
|
||
|
||
/// Parse a naive datetime string in formats produced by chrono's Display or JS frontends.
|
||
fn parse_naive_datetime(s: &str) -> Result<chrono::NaiveDateTime, chrono::ParseError> {
|
||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f")
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ"))
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ"))
|
||
}
|
||
|
||
/// Parse a timestamptz string in formats produced by chrono's Display or JS frontends.
|
||
fn parse_datetime_utc(s: &str) -> Result<chrono::DateTime<Utc>, chrono::ParseError> {
|
||
s.parse::<chrono::DateTime<Utc>>()
|
||
.or_else(|_| {
|
||
// Handle numeric timezone offsets: "2024-01-15 10:30:00+00", "+00:00", "+0000"
|
||
chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%#z")
|
||
.or_else(|_| chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%#z"))
|
||
.map(|dt| dt.with_timezone(&Utc))
|
||
})
|
||
.or_else(|_| {
|
||
// Handle chrono's Display format: "2024-01-15 10:30:00 UTC"
|
||
let trimmed = s.trim_end_matches(" UTC");
|
||
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S%.f")
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S"))
|
||
.or_else(|_| {
|
||
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.fZ")
|
||
})
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f"))
|
||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ"))
|
||
.map(|ndt| ndt.and_utc())
|
||
})
|
||
}
|
||
|
||
fn map_as_single_type<T>(
|
||
vec: Option<&Vec<Value>>,
|
||
f: impl Fn(&Value) -> Option<T>,
|
||
) -> anyhow::Result<Option<Vec<Option<T>>>> {
|
||
if let Some(vec) = vec {
|
||
Ok(Some(
|
||
vec.into_iter()
|
||
.map(|v| {
|
||
// first option is if the value is of the right type (if none, will stop the collection and throw error)
|
||
// second option is if the value is null
|
||
// allow nulls in arrays
|
||
if matches!(v, Value::Null) {
|
||
Some(None)
|
||
} else {
|
||
f(v).map(Some)
|
||
}
|
||
})
|
||
.collect::<Option<Vec<Option<T>>>>()
|
||
.ok_or_else(|| anyhow::anyhow!("Mixed types in array"))?,
|
||
))
|
||
} else {
|
||
Ok(None)
|
||
}
|
||
}
|
||
|
||
/// A boxed `ToSql` value paired with the Postgres `Type` that matches its
|
||
/// concrete Rust type. Returned by `convert_val` / `convert_vec_val` so the
|
||
/// dispatch in `do_postgresql_inner` always asserts the type that the encoder
|
||
/// can actually produce — never a parser-derived guess that drifts from the
|
||
/// runtime binding.
|
||
type ConvertedParam = (Box<dyn ToSql + Sync + Send>, Type);
|
||
|
||
fn convert_vec_val(
|
||
vec: Option<&Vec<Value>>,
|
||
arg_t: &String,
|
||
) -> windmill_common::error::Result<ConvertedParam> {
|
||
match arg_t.as_str() {
|
||
// Each integer / bool array arm accepts both JSON-native values AND
|
||
// stringified counterparts ("1", "true", …) — same coercion the
|
||
// scalar `Value::String → <type>` arms in `convert_val` apply, so an
|
||
// array passed via `JSON.stringify(BigInt(...))` or hand-quoted
|
||
// values doesn't trip a confusing "Mixed types in array" error.
|
||
"bool" | "boolean" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_bool()
|
||
.or_else(|| match v.as_str()?.to_ascii_lowercase().as_str() {
|
||
"true" | "t" | "yes" | "y" | "1" | "on" => Some(true),
|
||
"false" | "f" | "no" | "n" | "0" | "off" => Some(false),
|
||
_ => None,
|
||
})
|
||
})?),
|
||
Type::BOOL_ARRAY,
|
||
)),
|
||
"char" | "character" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_i64()
|
||
.map(|x| x as i8)
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<i8>().ok()))
|
||
})?),
|
||
Type::CHAR_ARRAY,
|
||
)),
|
||
"smallint" | "smallserial" | "int2" | "serial2" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_i64()
|
||
.map(|x| x as i16)
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<i16>().ok()))
|
||
})?),
|
||
Type::INT2_ARRAY,
|
||
)),
|
||
"int" | "integer" | "int4" | "serial" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_i64()
|
||
.map(|x| x as i32)
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<i32>().ok()))
|
||
})?),
|
||
Type::INT4_ARRAY,
|
||
)),
|
||
// Mirror the scalar `Value::String → numeric` parsing arm so an array
|
||
// like `["1.5", "2.5"]` works against `$1::numeric[]` — useful for
|
||
// bulk-loading via `unnest`. Without this the user would see an
|
||
// unhelpful "Mixed types in array" error.
|
||
"numeric" | "decimal" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
if v.is_i64() {
|
||
Decimal::from_i64(v.as_i64().unwrap())
|
||
} else if v.is_f64() {
|
||
Decimal::from_f64(v.as_f64().unwrap())
|
||
} else {
|
||
v.as_str().and_then(|s| s.parse::<Decimal>().ok())
|
||
}
|
||
})?),
|
||
Type::NUMERIC_ARRAY,
|
||
)),
|
||
"oid" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_u64()
|
||
.map(|x| x as u32)
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<u32>().ok()))
|
||
})?),
|
||
Type::OID_ARRAY,
|
||
)),
|
||
"bigint" | "bigserial" | "int8" | "serial8" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_i64()
|
||
.or_else(|| v.as_u64().map(|x| x as i64))
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
|
||
})?),
|
||
Type::INT8_ARRAY,
|
||
)),
|
||
"real" | "float4" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_f64()
|
||
.map(|x| x as f32)
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<f32>().ok()))
|
||
})?),
|
||
Type::FLOAT4_ARRAY,
|
||
)),
|
||
"double" | "double precision" | "float8" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_f64()
|
||
.or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
|
||
})?),
|
||
Type::FLOAT8_ARRAY,
|
||
)),
|
||
"uuid" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().map(|x| Uuid::parse_str(x).ok()).flatten()
|
||
})?),
|
||
Type::UUID_ARRAY,
|
||
)),
|
||
"date" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().and_then(|x| parse_naive_date(x).ok())
|
||
})?),
|
||
Type::DATE_ARRAY,
|
||
)),
|
||
"time" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().and_then(|x| parse_naive_time(x).ok())
|
||
})?),
|
||
Type::TIME_ARRAY,
|
||
)),
|
||
"timetz" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().and_then(|x| parse_naive_time(x).ok())
|
||
})?),
|
||
// chrono's `NaiveTime` only encodes for `TIME` — same caveat as
|
||
// the scalar `timetz` arm. Asserting `TIMETZ_ARRAY` here would
|
||
// fail at the encoder. Postgres has an implicit `time → timetz`
|
||
// assignment cast at the column site.
|
||
Type::TIME_ARRAY,
|
||
)),
|
||
"timestamp" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().and_then(|x| parse_naive_datetime(x).ok())
|
||
})?),
|
||
Type::TIMESTAMP_ARRAY,
|
||
)),
|
||
"timestamptz" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().and_then(|x| parse_datetime_utc(x).ok())
|
||
})?),
|
||
Type::TIMESTAMPTZ_ARRAY,
|
||
)),
|
||
"jsonb" => Ok((
|
||
Box::new(vec.map(|v| v.clone().into_iter().map(Some).collect_vec())),
|
||
Type::JSONB_ARRAY,
|
||
)),
|
||
"json" => Ok((
|
||
Box::new(vec.map(|v| v.clone().into_iter().map(Some).collect_vec())),
|
||
Type::JSON_ARRAY,
|
||
)),
|
||
"bytea" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().map(|x| {
|
||
engine::general_purpose::STANDARD
|
||
.decode(x)
|
||
.unwrap_or(vec![])
|
||
})
|
||
})?),
|
||
Type::BYTEA_ARRAY,
|
||
)),
|
||
"varchar" | "character varying" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().map(|x| x.to_string())
|
||
})?),
|
||
Type::VARCHAR_ARRAY,
|
||
)),
|
||
"text" => Ok((
|
||
Box::new(map_as_single_type(vec, |v| {
|
||
v.as_str().map(|x| x.to_string())
|
||
})?),
|
||
Type::TEXT_ARRAY,
|
||
)),
|
||
_ => Err(anyhow::anyhow!("Unsupported JSON array type"))?,
|
||
}
|
||
}
|
||
|
||
fn convert_val(
|
||
value: &Value,
|
||
arg_t: &String,
|
||
typ: &Typ,
|
||
otyp_inferred: bool,
|
||
) -> windmill_common::error::Result<ConvertedParam> {
|
||
// Helper: was the user's intent explicitly "text" / "varchar" / "char"?
|
||
// True when the parser saw an inline `$N::text` cast or a `-- $N (text)`
|
||
// declaration. False when the parser fell back to "text" because nothing
|
||
// else was found (in which case the caller has no real target type
|
||
// committed and we should bind the value's natural type).
|
||
let explicit_text_target = !otyp_inferred
|
||
&& (matches!(typ, Typ::Str(_))
|
||
&& (arg_t == "text"
|
||
|| arg_t == "varchar"
|
||
|| arg_t == "character varying"
|
||
|| arg_t == "char"
|
||
|| arg_t == "character"));
|
||
match value {
|
||
Value::Array(vec) if arg_t.ends_with("[]") => {
|
||
let arg_t = arg_t.trim_end_matches("[]").to_string();
|
||
convert_vec_val(Some(vec), &arg_t)
|
||
}
|
||
Value::Null if arg_t.ends_with("[]") => {
|
||
let arg_t = arg_t.trim_end_matches("[]").to_string();
|
||
convert_vec_val(None, &arg_t)
|
||
}
|
||
Value::Null => match arg_t.as_str() {
|
||
"bool" | "boolean" => Ok((Box::new(None::<bool>), Type::BOOL)),
|
||
"char" | "character" => Ok((Box::new(None::<i8>), Type::CHAR)),
|
||
"smallint" | "smallserial" | "int2" | "serial2" => {
|
||
Ok((Box::new(None::<i16>), Type::INT2))
|
||
}
|
||
"int" | "integer" | "int4" | "serial" => Ok((Box::new(None::<i32>), Type::INT4)),
|
||
"numeric" | "decimal" => Ok((Box::new(None::<Decimal>), Type::NUMERIC)),
|
||
"oid" => Ok((Box::new(None::<u32>), Type::OID)),
|
||
"bigint" | "bigserial" | "int8" | "serial8" => Ok((Box::new(None::<i64>), Type::INT8)),
|
||
"real" | "float4" => Ok((Box::new(None::<f32>), Type::FLOAT4)),
|
||
"double" | "double precision" | "float8" => Ok((Box::new(None::<f64>), Type::FLOAT8)),
|
||
"uuid" => Ok((Box::new(None::<Uuid>), Type::UUID)),
|
||
"date" => Ok((Box::new(None::<chrono::NaiveDate>), Type::DATE)),
|
||
"time" => Ok((Box::new(None::<chrono::NaiveTime>), Type::TIME)),
|
||
// chrono's NaiveTime has no timezone, so its ToSql impl only
|
||
// accepts TIME. We assert TIME and rely on Postgres' implicit
|
||
// assignment cast time → timetz at the use site.
|
||
"timetz" => Ok((Box::new(None::<chrono::NaiveTime>), Type::TIME)),
|
||
"timestamp" => Ok((Box::new(None::<chrono::NaiveDateTime>), Type::TIMESTAMP)),
|
||
"timestamptz" => Ok((Box::new(None::<chrono::DateTime<Utc>>), Type::TIMESTAMPTZ)),
|
||
"jsonb" => Ok((Box::new(None::<Value>), Type::JSONB)),
|
||
"json" => Ok((Box::new(None::<Value>), Type::JSON)),
|
||
"bytea" => Ok((Box::new(None::<Vec<u8>>), Type::BYTEA)),
|
||
"varchar" | "character varying" => Ok((Box::new(None::<String>), Type::VARCHAR)),
|
||
"text" => Ok((Box::new(None::<String>), Type::TEXT)),
|
||
// Unrecognised arg_t — bind as TEXT NULL. The dispatch will fall
|
||
// back to prepare + query_raw, where the server resolves the
|
||
// actual column type and `Option<String>`'s ToSql will accept the
|
||
// resolved Type for any text-like base; for enum/domain kinds
|
||
// None is encoded as the literal NULL message body, so the
|
||
// accepts() check is the only place that matters and we just need
|
||
// a binding whose accepts() is permissive enough.
|
||
_ => Ok((Box::new(None::<AnyTextValue>), Type::TEXT)),
|
||
},
|
||
// Bool / Number with an *explicitly* text-typed arg: coerce to
|
||
// String. Used when the user wrote `-- $N (text)` or `$N::text` —
|
||
// they committed to text and may rely on equality comparisons like
|
||
// `WHERE text_col = $1`, which need a `text = text` operator (PG has
|
||
// no implicit `bool/int → text` cast in expression context).
|
||
Value::Bool(b) if explicit_text_target => {
|
||
// `char` (Type::CHAR, OID 18) is single-byte and `to_string()` of
|
||
// a bool is multi-byte ("true"/"false") — we can't bind it as
|
||
// CHAR. Fail explicitly with an actionable hint rather than
|
||
// silently sending BOOL (which the server then can't compare
|
||
// against a CHAR column — `operator does not exist: bool = char`).
|
||
// `character` (= bpchar, fixed-length text) has the same issue.
|
||
// For text/varchar/character varying we coerce to a string.
|
||
match arg_t.as_str() {
|
||
"char" | "character" => Err(Error::ExecutionErr(format!(
|
||
"Cannot bind a JSON bool to a `{arg_t}` arg. \
|
||
`char` and `character` are single-byte / fixed-width text — \
|
||
pass the value as a string (e.g. \"t\" / \"f\") or change \
|
||
the arg type to `bool`."
|
||
))),
|
||
"varchar" | "character varying" => Ok((Box::new(b.to_string()), Type::VARCHAR)),
|
||
_ => Ok((Box::new(b.to_string()), Type::TEXT)),
|
||
}
|
||
}
|
||
// Bool: bind as BOOL when no explicit text target. Postgres has an
|
||
// implicit assignment cast bool→text, so INSERTs into text columns
|
||
// still work — this only differs from the explicit-text branch above
|
||
// for expression-context uses (WHERE clauses, etc.).
|
||
Value::Bool(_) if arg_t == "jsonb" => Ok((Box::new(value.clone()), Type::JSONB)),
|
||
Value::Bool(_) if arg_t == "json" => Ok((Box::new(value.clone()), Type::JSON)),
|
||
Value::Bool(b) => Ok((Box::new(b.clone()), Type::BOOL)),
|
||
// Number with an explicitly text-typed arg: coerce to String. Same
|
||
// reasoning as the Bool branch — preserves pre-#8988 behaviour for
|
||
// hand-written PG scripts that use `WHERE text_col = $1` with a
|
||
// numeric value and an explicit text declaration.
|
||
// Skip `char`/`character`: those go to the existing single-byte arm
|
||
// below or the generic INT8 fallthrough.
|
||
Value::Number(n)
|
||
if explicit_text_target
|
||
&& (arg_t == "text" || arg_t == "varchar" || arg_t == "character varying") =>
|
||
{
|
||
let t = if arg_t == "varchar" || arg_t == "character varying" {
|
||
Type::VARCHAR
|
||
} else {
|
||
Type::TEXT
|
||
};
|
||
Ok((Box::new(n.to_string()), t))
|
||
}
|
||
Value::Number(n) if arg_t == "char" && n.is_i64() => {
|
||
Ok((Box::new(n.as_i64().unwrap() as i8), Type::CHAR))
|
||
}
|
||
Value::Number(n)
|
||
if (arg_t == "smallint"
|
||
|| arg_t == "smallserial"
|
||
|| arg_t == "int2"
|
||
|| arg_t == "serial2")
|
||
&& n.is_i64() =>
|
||
{
|
||
Ok((Box::new(n.as_i64().unwrap() as i16), Type::INT2))
|
||
}
|
||
Value::Number(n)
|
||
if (arg_t == "int" || arg_t == "integer" || arg_t == "int4" || arg_t == "serial")
|
||
&& n.is_i64() =>
|
||
{
|
||
Ok((Box::new(n.as_i64().unwrap() as i32), Type::INT4))
|
||
}
|
||
Value::Number(n) if (arg_t == "real" || arg_t == "float4") && n.as_f64().is_some() => {
|
||
Ok((Box::new(n.as_f64().unwrap() as f32), Type::FLOAT4))
|
||
}
|
||
Value::Number(n)
|
||
if (arg_t == "double" || arg_t == "double precision" || arg_t == "float8")
|
||
&& n.as_f64().is_some() =>
|
||
{
|
||
Ok((Box::new(n.as_f64().unwrap()), Type::FLOAT8))
|
||
}
|
||
Value::Number(n) if (arg_t == "numeric" || arg_t == "decimal") && n.is_i64() => Ok((
|
||
Box::new(Decimal::from_i64(n.as_i64().unwrap()).unwrap_or_default()),
|
||
Type::NUMERIC,
|
||
)),
|
||
Value::Number(n) if (arg_t == "numeric" || arg_t == "decimal") && n.is_f64() => Ok((
|
||
Box::new(Decimal::from_f64(n.as_f64().unwrap()).unwrap_or_default()),
|
||
Type::NUMERIC,
|
||
)),
|
||
Value::Number(n) if arg_t == "oid" && n.is_u64() => {
|
||
Ok((Box::new(n.as_u64().unwrap() as u32), Type::OID))
|
||
}
|
||
Value::Number(n)
|
||
if (arg_t == "bigint"
|
||
|| arg_t == "bigserial"
|
||
|| arg_t == "int8"
|
||
|| arg_t == "serial8")
|
||
&& n.is_u64() =>
|
||
{
|
||
Ok((Box::new(n.as_u64().unwrap() as i64), Type::INT8))
|
||
}
|
||
Value::Number(n) if n.is_i64() => Ok((Box::new(n.as_i64().unwrap()), Type::INT8)),
|
||
Value::Number(n) => Ok((Box::new(n.as_f64().unwrap()), Type::FLOAT8)),
|
||
Value::String(s) if arg_t == "uuid" => Ok((Box::new(Uuid::parse_str(s)?), Type::UUID)),
|
||
Value::String(s)
|
||
if arg_t == "smallint"
|
||
|| arg_t == "smallserial"
|
||
|| arg_t == "int2"
|
||
|| arg_t == "serial2" =>
|
||
{
|
||
s.parse::<i16>()
|
||
.map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::INT2))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as smallint: {e}").into())
|
||
}
|
||
Value::String(s)
|
||
if arg_t == "int" || arg_t == "integer" || arg_t == "int4" || arg_t == "serial" =>
|
||
{
|
||
s.parse::<i32>()
|
||
.map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::INT4))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as integer: {e}").into())
|
||
}
|
||
Value::String(s)
|
||
if arg_t == "bigint"
|
||
|| arg_t == "bigserial"
|
||
|| arg_t == "int8"
|
||
|| arg_t == "serial8" =>
|
||
{
|
||
s.parse::<i64>()
|
||
.map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::INT8))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as bigint: {e}").into())
|
||
}
|
||
Value::String(s) if arg_t == "date" => {
|
||
let date = parse_naive_date(s)
|
||
.map_err(|e| Error::ExecutionErr(format!("Cannot parse '{s}' as date: {e}")))?;
|
||
Ok((Box::new(date), Type::DATE))
|
||
}
|
||
Value::String(s) if arg_t == "time" => {
|
||
let time = parse_naive_time(s)
|
||
.map_err(|e| Error::ExecutionErr(format!("Cannot parse '{s}' as time: {e}")))?;
|
||
Ok((Box::new(time), Type::TIME))
|
||
}
|
||
Value::String(s) if arg_t == "timetz" => {
|
||
let time = parse_naive_time(s)
|
||
.map_err(|e| Error::ExecutionErr(format!("Cannot parse '{s}' as time: {e}")))?;
|
||
// See the timetz Null arm — assert TIME, server casts to TIMETZ.
|
||
Ok((Box::new(time), Type::TIME))
|
||
}
|
||
Value::String(s) if arg_t == "timestamp" => {
|
||
let datetime = parse_naive_datetime(s).map_err(|e| {
|
||
Error::ExecutionErr(format!("Cannot parse '{s}' as timestamp: {e}"))
|
||
})?;
|
||
Ok((Box::new(datetime), Type::TIMESTAMP))
|
||
}
|
||
Value::String(s) if arg_t == "timestamptz" => {
|
||
let datetime = parse_datetime_utc(s).map_err(|e| {
|
||
Error::ExecutionErr(format!("Cannot parse '{s}' as timestamptz: {e}"))
|
||
})?;
|
||
Ok((Box::new(datetime), Type::TIMESTAMPTZ))
|
||
}
|
||
Value::String(s) if arg_t == "bytea" => {
|
||
let bytes = engine::general_purpose::STANDARD
|
||
.decode(s)
|
||
.unwrap_or(vec![]);
|
||
Ok((Box::new(bytes), Type::BYTEA))
|
||
}
|
||
// Parse Strings into the matching native Rust type for the remaining
|
||
// recognised arg_ts that didn't have a dedicated arm. Without these,
|
||
// a string value lands in the generic Value::String fallback below
|
||
// (Box<String> + TEXT) and the server-side comparison
|
||
// `<numeric|real|...> = text` fails since PG has no implicit cast.
|
||
Value::String(s) if arg_t == "numeric" || arg_t == "decimal" => s
|
||
.parse::<Decimal>()
|
||
.map(|d| (Box::new(d) as Box<dyn ToSql + Sync + Send>, Type::NUMERIC))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as numeric: {e}").into()),
|
||
Value::String(s) if arg_t == "real" || arg_t == "float4" => s
|
||
.parse::<f32>()
|
||
.map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::FLOAT4))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as real: {e}").into()),
|
||
Value::String(s)
|
||
if arg_t == "double" || arg_t == "double precision" || arg_t == "float8" =>
|
||
{
|
||
s.parse::<f64>()
|
||
.map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::FLOAT8))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as double: {e}").into())
|
||
}
|
||
Value::String(s) if arg_t == "oid" => s
|
||
.parse::<u32>()
|
||
.map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::OID))
|
||
.map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as oid: {e}").into()),
|
||
Value::String(s) if arg_t == "bool" || arg_t == "boolean" => {
|
||
// Accept the same literals Postgres' boolin() does.
|
||
let b = match s.to_ascii_lowercase().as_str() {
|
||
"true" | "t" | "yes" | "y" | "1" | "on" => true,
|
||
"false" | "f" | "no" | "n" | "0" | "off" => false,
|
||
_ => {
|
||
return Err(
|
||
anyhow::anyhow!("Cannot parse '{s}' as bool: invalid literal").into(),
|
||
)
|
||
}
|
||
};
|
||
Ok((Box::new(b), Type::BOOL))
|
||
}
|
||
Value::String(s) if arg_t == "varchar" || arg_t == "character varying" => {
|
||
Ok((Box::new(s.clone()), Type::VARCHAR))
|
||
}
|
||
// For arg_t in (json, jsonb): bind a JSON-encodable Value with the
|
||
// matching pg type. Falling through to TEXT here would assert TEXT
|
||
// and break query_typed_raw's encoder check.
|
||
// Object / Array (no `[]` suffix): bind as JSONB by default and
|
||
// JSON-stringify when the target is text-like.
|
||
//
|
||
// Note the asymmetry vs the Bool/Number arms above: we coerce to
|
||
// text on `matches!(typ, Typ::Str(_))` (which is true for both
|
||
// explicit `(text)` decls AND parser-default text), not on
|
||
// `explicit_text_target`. Reason: serialising a JSON object/array
|
||
// as JSONB and binding against a parser-default-text arg would
|
||
// assert `JSONB` for what could be a plain-text column. Postgres
|
||
// has no implicit cast `jsonb → text` in expression context, so
|
||
// `WHERE text_col = $1::JSONB` would fail. JSON-stringifying into
|
||
// TEXT is what users almost always want for these JSON shapes
|
||
// (and the result is itself valid JSON, so a `::jsonb` cast in
|
||
// SQL still round-trips). Bool/Number don't need this safety
|
||
// because `bool → text` and `int → text` have implicit assignment
|
||
// casts; the asymmetry is therefore semantic, not a bug.
|
||
Value::Array(_) if arg_t == "jsonb" => Ok((Box::new(value.clone()), Type::JSONB)),
|
||
Value::Array(_) if arg_t == "json" => Ok((Box::new(value.clone()), Type::JSON)),
|
||
Value::Array(_) if matches!(typ, Typ::Str(_)) => {
|
||
let s = serde_json::to_string(value).map_err(|err| {
|
||
Error::ExecutionErr(format!("Failed to convert JSON to text: {}", err))
|
||
})?;
|
||
let t = if arg_t == "varchar" {
|
||
Type::VARCHAR
|
||
} else {
|
||
Type::TEXT
|
||
};
|
||
Ok((Box::new(s), t))
|
||
}
|
||
// Default for arrays without a [] suffix: bind as JSONB.
|
||
Value::Array(_) => Ok((Box::new(value.clone()), Type::JSONB)),
|
||
Value::Object(_) if arg_t == "json" => Ok((Box::new(value.clone()), Type::JSON)),
|
||
Value::Object(_) if arg_t == "varchar" || arg_t == "character varying" => Ok((
|
||
Box::new(serde_json::to_string(value).map_err(|err| {
|
||
Error::ExecutionErr(format!("Failed to convert JSON to text: {}", err))
|
||
})?),
|
||
Type::VARCHAR,
|
||
)),
|
||
Value::Object(_) if arg_t == "text" || matches!(typ, Typ::Str(_)) => Ok((
|
||
Box::new(serde_json::to_string(value).map_err(|err| {
|
||
Error::ExecutionErr(format!("Failed to convert JSON to text: {}", err))
|
||
})?),
|
||
Type::TEXT,
|
||
)),
|
||
Value::Object(_) => Ok((Box::new(value.clone()), Type::JSONB)),
|
||
// Generic String fallback. Use `AnyTextValue` (rather than plain
|
||
// `String`) so the binding's `accepts()` covers `Kind::Enum` and
|
||
// `Kind::Domain` in addition to the base text types — this is what
|
||
// makes `INSERT INTO t VALUES ($1::my_enum)` work end-to-end without
|
||
// users needing the `CAST($1::text AS my_enum)` workaround.
|
||
//
|
||
// We always assert `Type::TEXT` (not `Type::UNKNOWN`): tokio_postgres
|
||
// sends parameter values in binary format, and Postgres rejects
|
||
// binary-formatted bytes for `UNKNOWN` parameters in operator
|
||
// contexts ("incorrect binary data format in bind parameter N"). The
|
||
// trade-off is that bare `$1` against a non-text column still needs
|
||
// an explicit cast (`$1::my_enum`), but the failure mode is a clear
|
||
// server error rather than a cryptic protocol mismatch.
|
||
Value::String(s) => Ok((Box::new(AnyTextValue(s.clone())), Type::TEXT)),
|
||
}
|
||
}
|
||
|
||
/// Hard cap on how many `numeric` cells we test for f64-precision loss per
|
||
/// query. The check is `Decimal -> f64 -> Decimal` round-trip + `==` (~tens
|
||
/// of ns each); on a query returning millions of numeric cells, an
|
||
/// unbounded check would add measurable latency. After this many "fits
|
||
/// fine" observations we assume the rest do too — the pathological case
|
||
/// (rows 1..N fit, row N+1 loses precision) goes silently truncated, but
|
||
/// users who care about precision in such results can `::text`-cast their
|
||
/// SQL anyway. The first cell that does NOT fit short-circuits the budget
|
||
/// (the warning fires once and the per-row check stops immediately).
|
||
const NUMERIC_PRECISION_CHECK_BUDGET: u32 = 256;
|
||
|
||
/// Per-query state carried through result formatting. Currently used to
|
||
/// detect precision loss on the first `numeric` cell that doesn't round-trip
|
||
/// through f64, so the caller can emit a single warning per job rather than
|
||
/// silently truncating every row. Uses atomics (rather than `Cell`) so the
|
||
/// s3-streaming path — which moves the closure across futures and requires
|
||
/// `Send` — can borrow it.
|
||
pub struct ResultFormatState {
|
||
/// `true` once we've observed a `numeric` value that loses precision when
|
||
/// converted via f64. Once flipped, the per-row check short-circuits.
|
||
pub numeric_precision_loss: std::sync::atomic::AtomicBool,
|
||
/// Decremented for each `numeric` cell we actually check. When it hits 0
|
||
/// the per-row check is skipped (along with the precision-loss flag) for
|
||
/// the rest of the query — see the rationale on
|
||
/// `NUMERIC_PRECISION_CHECK_BUDGET`.
|
||
numeric_precision_check_budget: std::sync::atomic::AtomicU32,
|
||
}
|
||
|
||
impl Default for ResultFormatState {
|
||
fn default() -> Self {
|
||
Self {
|
||
numeric_precision_loss: std::sync::atomic::AtomicBool::new(false),
|
||
numeric_precision_check_budget: std::sync::atomic::AtomicU32::new(
|
||
NUMERIC_PRECISION_CHECK_BUDGET,
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn pg_cell_to_json_value(
|
||
row: &Row,
|
||
column: &Column,
|
||
column_i: usize,
|
||
) -> Result<JSONValue, Error> {
|
||
pg_cell_to_json_value_with_state(row, column, column_i, &ResultFormatState::default())
|
||
}
|
||
|
||
pub fn pg_cell_to_json_value_with_state(
|
||
row: &Row,
|
||
column: &Column,
|
||
column_i: usize,
|
||
state: &ResultFormatState,
|
||
) -> Result<JSONValue, Error> {
|
||
// JSON has no encoding for NaN / +Inf / -Inf, but Postgres `float4` /
|
||
// `float8` (and `numeric`, via the special `'NaN'` value) do return them.
|
||
// Pre-fix the worker errored with "invalid json-float", failing the
|
||
// entire query. Round-trip these as JSON strings ("NaN", "Infinity",
|
||
// "-Infinity") so the rest of the row still comes through; users who
|
||
// need numeric semantics can filter them out client-side.
|
||
let f64_to_json_number = |raw_val: f64| -> Result<JSONValue, Error> {
|
||
if raw_val.is_nan() {
|
||
return Ok(JSONValue::String("NaN".to_string()));
|
||
}
|
||
if raw_val.is_infinite() {
|
||
return Ok(JSONValue::String(if raw_val > 0.0 {
|
||
"Infinity".to_string()
|
||
} else {
|
||
"-Infinity".to_string()
|
||
}));
|
||
}
|
||
let temp =
|
||
serde_json::Number::from_f64(raw_val).ok_or(anyhow::anyhow!("invalid json-float"))?;
|
||
Ok(JSONValue::Number(temp))
|
||
};
|
||
Ok(match *column.type_() {
|
||
// for rust-postgres <> postgres type-mappings: https://docs.rs/postgres/latest/postgres/types/trait.FromSql.html#types
|
||
// for postgres types: https://www.postgresql.org/docs/7.4/datatype.html#DATATYPE-TABLE
|
||
|
||
// single types
|
||
Type::BOOL => get_basic(row, column, column_i, |a: bool| Ok(JSONValue::Bool(a)))?,
|
||
Type::BIT => get_basic(row, column, column_i, |a: bit_vec::BitVec| match a.len() {
|
||
1 => Ok(JSONValue::Bool(a.get(0).unwrap())),
|
||
_ => Ok(JSONValue::String(
|
||
a.iter()
|
||
.map(|x| if x { "1" } else { "0" })
|
||
.collect::<String>(),
|
||
)),
|
||
})?,
|
||
Type::INT2 => get_basic(row, column, column_i, |a: i16| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
Type::INT4 => get_basic(row, column, column_i, |a: i32| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
Type::INT8 => get_basic(row, column, column_i, |a: i64| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
Type::TEXT | Type::VARCHAR => {
|
||
get_basic(row, column, column_i, |a: String| Ok(JSONValue::String(a)))?
|
||
}
|
||
// ISO-8601 / RFC-3339 for temporal types so values round-trip through
|
||
// JS / Python clients (`new Date(s)`, `datetime.fromisoformat(s)`)
|
||
// without manual parsing. chrono's default `to_string()` returns
|
||
// space-separated for naive datetimes and " UTC" suffix for tz-aware,
|
||
// neither of which is parseable as ISO 8601.
|
||
Type::TIMESTAMP => get_basic(row, column, column_i, |a: chrono::NaiveDateTime| {
|
||
Ok(JSONValue::String(format_naive_datetime_iso(&a)))
|
||
})?,
|
||
Type::DATE => get_basic(row, column, column_i, |a: chrono::NaiveDate| {
|
||
// chrono's `NaiveDate::to_string` is already ISO-8601 (`%Y-%m-%d`).
|
||
Ok(JSONValue::String(a.to_string()))
|
||
})?,
|
||
Type::TIME => get_basic(row, column, column_i, |a: chrono::NaiveTime| {
|
||
// `NaiveTime::to_string` is already ISO-8601 (`%H:%M:%S` with
|
||
// optional `.f`).
|
||
Ok(JSONValue::String(a.to_string()))
|
||
})?,
|
||
Type::TIMETZ => get_basic(row, column, column_i, |a: TimeTZStr| {
|
||
// TimeTZStr's `from_sql` already formats as ISO-8601 (see impl
|
||
// below).
|
||
Ok(JSONValue::String(a.0))
|
||
})?,
|
||
Type::TIMESTAMPTZ => get_basic(row, column, column_i, |a: chrono::DateTime<Utc>| {
|
||
Ok(JSONValue::String(a.to_rfc3339()))
|
||
})?,
|
||
Type::UUID => get_basic(row, column, column_i, |a: uuid::Uuid| {
|
||
Ok(JSONValue::String(a.to_string()))
|
||
})?,
|
||
Type::INET => get_basic(row, column, column_i, |a: IpAddr| {
|
||
Ok(JSONValue::String(a.to_string()))
|
||
})?,
|
||
Type::INTERVAL => get_basic(row, column, column_i, |a: IntervalStr| {
|
||
Ok(JSONValue::String(a.0))
|
||
})?,
|
||
Type::JSON | Type::JSONB => get_basic(row, column, column_i, |a: JSONValue| Ok(a))?,
|
||
Type::FLOAT4 => get_basic(row, column, column_i, |a: f32| {
|
||
Ok(f64_to_json_number(a.into())?)
|
||
})?,
|
||
// Pre-existing behaviour: `numeric` is serialised as a JSON Number
|
||
// via `Decimal::serialize`, which goes through f64 and silently
|
||
// truncates past ~15-17 significant digits. Switching to JSON String
|
||
// would preserve precision but break any user script doing arithmetic
|
||
// / comparison on numeric column results (`row.amount + 1` becomes
|
||
// string concat, `row.amount > 100` is lexicographic). Left as Number
|
||
// for back-compat. Instead, on the FIRST cell whose decimal
|
||
// representation can't round-trip through f64, we flip
|
||
// `state.numeric_precision_loss` so the caller can emit a single
|
||
// job-log warning recommending a `::text` cast. The check is bounded
|
||
// by `NUMERIC_PRECISION_CHECK_BUDGET` cells (see comment there) and
|
||
// short-circuits on the first lossy value, so the hot path on a
|
||
// numeric-heavy result set is two atomic loads + an early return.
|
||
Type::NUMERIC => get_basic(row, column, column_i, |a: Decimal| {
|
||
if state.should_check_precision() && !decimal_fits_f64_losslessly(&a) {
|
||
state
|
||
.numeric_precision_loss
|
||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
Ok(serde_json::to_value(a)
|
||
.map_err(|_| anyhow::anyhow!("Cannot convert decimal to json"))?)
|
||
})?,
|
||
Type::FLOAT8 => get_basic(row, column, column_i, |a: f64| f64_to_json_number(a))?,
|
||
Type::BYTEA => get_basic(row, column, column_i, |a: Vec<u8>| {
|
||
Ok(JSONValue::String(format!("\\x{}", hex::encode(a))))
|
||
})?,
|
||
// these types require a custom StringCollector struct as an intermediary (see struct at bottom)
|
||
Type::TS_VECTOR => get_basic(row, column, column_i, |a: StringCollector| {
|
||
Ok(JSONValue::String(a.0))
|
||
})?,
|
||
Type::OID => get_basic(row, column, column_i, |a: u32| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
// array types
|
||
Type::BOOL_ARRAY => get_array(row, column, column_i, |a: bool| Ok(JSONValue::Bool(a)))?,
|
||
Type::BIT_ARRAY => get_array(row, column, column_i, |a: bit_vec::BitVec| match a.len() {
|
||
1 => Ok(JSONValue::Bool(a.get(0).unwrap())),
|
||
_ => Ok(JSONValue::String(
|
||
a.iter()
|
||
.map(|x| if x { "1" } else { "0" })
|
||
.collect::<String>(),
|
||
)),
|
||
})?,
|
||
Type::INT2_ARRAY => get_array(row, column, column_i, |a: i16| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
Type::INT4_ARRAY => get_array(row, column, column_i, |a: i32| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
Type::INT8_ARRAY => get_array(row, column, column_i, |a: i64| {
|
||
Ok(JSONValue::Number(serde_json::Number::from(a)))
|
||
})?,
|
||
Type::TEXT_ARRAY | Type::VARCHAR_ARRAY => {
|
||
get_array(row, column, column_i, |a: String| Ok(JSONValue::String(a)))?
|
||
}
|
||
Type::JSON_ARRAY | Type::JSONB_ARRAY => {
|
||
get_array(row, column, column_i, |a: JSONValue| Ok(a))?
|
||
}
|
||
Type::FLOAT4_ARRAY => get_array(row, column, column_i, |a: f32| {
|
||
Ok(f64_to_json_number(a.into())?)
|
||
})?,
|
||
Type::FLOAT8_ARRAY => {
|
||
get_array(row, column, column_i, |a: f64| Ok(f64_to_json_number(a)?))?
|
||
}
|
||
// See scalar NUMERIC arm — kept as JSON Number for back-compat,
|
||
// with bounded precision-loss detection.
|
||
Type::NUMERIC_ARRAY => get_array(row, column, column_i, |a: Decimal| {
|
||
if state.should_check_precision() && !decimal_fits_f64_losslessly(&a) {
|
||
state
|
||
.numeric_precision_loss
|
||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
Ok(serde_json::to_value(a)
|
||
.map_err(|_| anyhow::anyhow!("Cannot convert decimal to json"))?)
|
||
})?,
|
||
// these types require a custom StringCollector struct as an intermediary (see struct at bottom)
|
||
Type::TS_VECTOR_ARRAY => get_array(row, column, column_i, |a: StringCollector| {
|
||
Ok(JSONValue::String(a.0))
|
||
})?,
|
||
// Same ISO-8601 formatting as the scalar arms above.
|
||
Type::TIMESTAMP_ARRAY => get_array(row, column, column_i, |a: chrono::NaiveDateTime| {
|
||
Ok(JSONValue::String(format_naive_datetime_iso(&a)))
|
||
})?,
|
||
Type::DATE_ARRAY => get_array(row, column, column_i, |a: chrono::NaiveDate| {
|
||
Ok(JSONValue::String(a.to_string()))
|
||
})?,
|
||
Type::TIME_ARRAY => get_array(row, column, column_i, |a: chrono::NaiveTime| {
|
||
Ok(JSONValue::String(a.to_string()))
|
||
})?,
|
||
Type::TIMETZ_ARRAY => get_array(row, column, column_i, |a: TimeTZStr| {
|
||
Ok(JSONValue::String(a.0))
|
||
})?,
|
||
Type::TIMESTAMPTZ_ARRAY => get_array(row, column, column_i, |a: chrono::DateTime<Utc>| {
|
||
Ok(JSONValue::String(a.to_rfc3339()))
|
||
})?,
|
||
Type::BYTEA_ARRAY => get_array(row, column, column_i, |a: Vec<u8>| {
|
||
Ok(JSONValue::String(format!("\\x{}", hex::encode(a))))
|
||
})?,
|
||
Type::VOID => JSONValue::Null,
|
||
// Default fallback for unhandled column types: read as text. We use
|
||
// `AnyTextValue` instead of plain `String` so that `Kind::Enum`,
|
||
// `Kind::Domain`, and citext columns round-trip into JSON strings
|
||
// rather than erroring with `cannot convert between Option<String>
|
||
// and the Postgres type \`<custom>\``.
|
||
_ => get_basic(row, column, column_i, |a: AnyTextValue| {
|
||
Ok(JSONValue::String(a.0))
|
||
})?,
|
||
})
|
||
}
|
||
|
||
pub fn postgres_row_to_json_value(row: Row) -> Result<JSONValue, Error> {
|
||
postgres_row_to_json_value_with_state(row, &ResultFormatState::default())
|
||
}
|
||
|
||
pub fn postgres_row_to_json_value_with_state(
|
||
row: Row,
|
||
state: &ResultFormatState,
|
||
) -> Result<JSONValue, Error> {
|
||
let row_data = postgres_row_to_row_data_with_state(row, state)?;
|
||
Ok(JSONValue::Object(row_data))
|
||
}
|
||
|
||
// some type-aliases I use in my project
|
||
pub type JSONValue = serde_json::Value;
|
||
pub type RowData = Map<String, JSONValue>;
|
||
|
||
pub fn postgres_row_to_row_data(row: Row) -> Result<RowData, Error> {
|
||
postgres_row_to_row_data_with_state(row, &ResultFormatState::default())
|
||
}
|
||
|
||
pub fn postgres_row_to_row_data_with_state(
|
||
row: Row,
|
||
state: &ResultFormatState,
|
||
) -> Result<RowData, Error> {
|
||
let mut result: Map<String, JSONValue> = Map::new();
|
||
for (i, column) in row.columns().iter().enumerate() {
|
||
let name = column.name();
|
||
let json_value = pg_cell_to_json_value_with_state(&row, column, i, state)?;
|
||
result.insert(name.to_string(), json_value);
|
||
}
|
||
Ok(result)
|
||
}
|
||
|
||
/// Returns true if the `Decimal` value can round-trip through `f64` without
|
||
/// losing precision. Used to detect when the user's `numeric` results are
|
||
/// being silently truncated by the JSON Number serialisation path so the
|
||
/// worker can log a one-shot warning recommending a `::text` cast in SQL.
|
||
fn decimal_fits_f64_losslessly(d: &Decimal) -> bool {
|
||
use rust_decimal::prelude::ToPrimitive;
|
||
match d.to_f64() {
|
||
Some(f) if f.is_finite() => Decimal::from_f64(f).is_some_and(|round| &round == d),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn get_basic<'a, T: FromSql<'a>>(
|
||
row: &'a Row,
|
||
column: &Column,
|
||
column_i: usize,
|
||
val_to_json_val: impl Fn(T) -> Result<JSONValue, Error>,
|
||
) -> Result<JSONValue, Error> {
|
||
let raw_val = row.try_get::<_, Option<T>>(column_i).with_context(|| {
|
||
format!(
|
||
"conversion issue for value at column_name `{}` with type {:?}",
|
||
column.name(),
|
||
column.type_()
|
||
)
|
||
})?;
|
||
raw_val.map_or(Ok(JSONValue::Null), val_to_json_val)
|
||
}
|
||
|
||
struct IntervalStr(String);
|
||
|
||
impl<'a> FromSql<'a> for IntervalStr {
|
||
fn from_sql(
|
||
_: &Type,
|
||
mut raw: &'a [u8],
|
||
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
||
let microseconds = raw.get_i64();
|
||
let days = raw.get_i32();
|
||
let months = raw.get_i32();
|
||
Ok(IntervalStr(format!(
|
||
"{:?} months {:?} days {:?} ms",
|
||
months, days, microseconds
|
||
)))
|
||
}
|
||
|
||
fn accepts(ty: &Type) -> bool {
|
||
matches!(ty, &Type::INTERVAL)
|
||
}
|
||
}
|
||
|
||
struct TimeTZStr(String);
|
||
impl<'a> FromSql<'a> for TimeTZStr {
|
||
fn from_sql(
|
||
_: &Type,
|
||
mut raw: &'a [u8],
|
||
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
||
let microsecond = raw.get_i64();
|
||
let offset = raw.get_i32();
|
||
let utc_sec = (microsecond / 1_000_000) + offset as i64;
|
||
let utc = chrono::NaiveTime::from_num_seconds_from_midnight_opt(
|
||
((utc_sec + 3600 * 24) % (3600 * 24)) as u32,
|
||
((microsecond % 1_000_000) * 1_000) as u32,
|
||
)
|
||
.ok_or_else(|| anyhow::anyhow!("Invalid time value"))?;
|
||
// ISO-8601: append `+00:00` since TIMETZ is normalised to UTC here.
|
||
Ok(TimeTZStr(format!("{}+00:00", utc)))
|
||
}
|
||
|
||
fn accepts(ty: &Type) -> bool {
|
||
matches!(ty, &Type::TIMETZ)
|
||
}
|
||
}
|
||
|
||
/// Format a `NaiveDateTime` as ISO-8601 (`YYYY-MM-DDTHH:MM:SS[.fff…]`).
|
||
/// chrono's default `to_string` uses a space separator, which is not parseable
|
||
/// by `new Date(s)` in older JS engines or Python's `datetime.fromisoformat`
|
||
/// before 3.11. Use the explicit format string so output is portable.
|
||
fn format_naive_datetime_iso(dt: &chrono::NaiveDateTime) -> String {
|
||
if dt.and_utc().timestamp_subsec_nanos() == 0 {
|
||
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||
} else {
|
||
dt.format("%Y-%m-%dT%H:%M:%S%.f").to_string()
|
||
}
|
||
}
|
||
|
||
fn get_array<'a, T: FromSql<'a>>(
|
||
row: &'a Row,
|
||
column: &Column,
|
||
column_i: usize,
|
||
val_to_json_val: impl Fn(T) -> Result<JSONValue, Error>,
|
||
) -> Result<JSONValue, Error> {
|
||
let raw_val_array = row
|
||
.try_get::<_, Option<Vec<Option<T>>>>(column_i)
|
||
.with_context(|| {
|
||
format!(
|
||
"conversion issue for array at column_name `{}`",
|
||
column.name()
|
||
)
|
||
})?;
|
||
Ok(match raw_val_array {
|
||
Some(val_array) => {
|
||
let mut result = vec![];
|
||
for val in val_array {
|
||
result.push(
|
||
val.map(|v| val_to_json_val(v))
|
||
.transpose()?
|
||
.unwrap_or(Value::Null),
|
||
);
|
||
}
|
||
JSONValue::Array(result)
|
||
}
|
||
None => JSONValue::Null,
|
||
})
|
||
}
|
||
|
||
// you can remove this section if not using TS_VECTOR (or other types requiring an intermediary `FromSQL` struct)
|
||
struct StringCollector(String);
|
||
impl FromSql<'_> for StringCollector {
|
||
fn from_sql(
|
||
_: &Type,
|
||
raw: &[u8],
|
||
) -> Result<StringCollector, Box<dyn std::error::Error + Sync + Send>> {
|
||
let result = std::str::from_utf8(raw)?;
|
||
Ok(StringCollector(result.to_owned()))
|
||
}
|
||
fn accepts(_ty: &Type) -> bool {
|
||
true
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_parse_naive_date() {
|
||
// chrono's NaiveDate::to_string() format
|
||
let d = parse_naive_date("2024-01-15").unwrap();
|
||
assert_eq!(d.to_string(), "2024-01-15");
|
||
|
||
// JS ISO format
|
||
let d = parse_naive_date("2024-01-15T00:00:00.000Z").unwrap();
|
||
assert_eq!(d.to_string(), "2024-01-15");
|
||
|
||
// ISO without fractional seconds
|
||
let d = parse_naive_date("2024-01-15T00:00:00Z").unwrap();
|
||
assert_eq!(d.to_string(), "2024-01-15");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_naive_time() {
|
||
// chrono's NaiveTime::to_string() format
|
||
let t = parse_naive_time("10:30:00").unwrap();
|
||
assert_eq!(t.to_string(), "10:30:00");
|
||
|
||
// With fractional seconds
|
||
let t = parse_naive_time("10:30:00.123456").unwrap();
|
||
assert_eq!(t.to_string(), "10:30:00.123456");
|
||
|
||
// Short format
|
||
let t = parse_naive_time("10:30").unwrap();
|
||
assert_eq!(t.to_string(), "10:30:00");
|
||
|
||
// From full datetime string (JS frontend)
|
||
let t = parse_naive_time("1970-01-01T10:30:00.000Z").unwrap();
|
||
assert_eq!(t.to_string(), "10:30:00");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_naive_datetime() {
|
||
// chrono's NaiveDateTime::to_string() format
|
||
let dt = parse_naive_datetime("2024-01-15 10:30:00").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00");
|
||
|
||
// With fractional seconds
|
||
let dt = parse_naive_datetime("2024-01-15 10:30:00.123456").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456");
|
||
|
||
// ISO format with Z
|
||
let dt = parse_naive_datetime("2024-01-15T10:30:00.000Z").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00");
|
||
|
||
// ISO format without Z
|
||
let dt = parse_naive_datetime("2024-01-15T10:30:00.000").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00");
|
||
|
||
// ISO without fractional seconds
|
||
let dt = parse_naive_datetime("2024-01-15T10:30:00Z").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_datetime_utc() {
|
||
// chrono's DateTime<Utc>::to_string() format
|
||
let dt = parse_datetime_utc("2024-01-15 10:30:00 UTC").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00 UTC");
|
||
|
||
// With fractional seconds
|
||
let dt = parse_datetime_utc("2024-01-15 10:30:00.123456 UTC").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456 UTC");
|
||
|
||
// RFC 3339
|
||
let dt = parse_datetime_utc("2024-01-15T10:30:00Z").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00 UTC");
|
||
|
||
// RFC 3339 with fractional seconds
|
||
let dt = parse_datetime_utc("2024-01-15T10:30:00.123Z").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123 UTC");
|
||
|
||
// Numeric timezone offset (PostgreSQL text representation)
|
||
let dt = parse_datetime_utc("2026-04-14 18:09:00+00").unwrap();
|
||
assert_eq!(dt.to_string(), "2026-04-14 18:09:00 UTC");
|
||
|
||
// Numeric timezone offset with fractional seconds
|
||
let dt = parse_datetime_utc("2024-01-15 10:30:00.123+02").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 08:30:00.123 UTC");
|
||
|
||
// Full offset format +00:00
|
||
let dt = parse_datetime_utc("2024-01-15 10:30:00+00:00").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00 UTC");
|
||
|
||
// ISO without timezone (treated as UTC)
|
||
let dt = parse_datetime_utc("2024-01-15T10:30:00.000").unwrap();
|
||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00 UTC");
|
||
}
|
||
|
||
#[test]
|
||
fn test_roundtrip_timestamp_formats() {
|
||
// Verify that the format produced by pg_cell_to_json_value can be parsed back
|
||
let original = chrono::NaiveDateTime::parse_from_str(
|
||
"2024-06-15 14:30:45.123",
|
||
"%Y-%m-%d %H:%M:%S%.f",
|
||
)
|
||
.unwrap();
|
||
let serialized = original.to_string();
|
||
let parsed = parse_naive_datetime(&serialized).unwrap();
|
||
assert_eq!(original, parsed);
|
||
}
|
||
|
||
#[test]
|
||
fn test_roundtrip_timestamptz_formats() {
|
||
let original = "2024-06-15T14:30:45Z"
|
||
.parse::<chrono::DateTime<Utc>>()
|
||
.unwrap();
|
||
let serialized = original.to_string();
|
||
let parsed = parse_datetime_utc(&serialized).unwrap();
|
||
assert_eq!(original, parsed);
|
||
}
|
||
|
||
#[test]
|
||
fn test_roundtrip_time_formats() {
|
||
let original = chrono::NaiveTime::parse_from_str("14:30:45.123", "%H:%M:%S%.f").unwrap();
|
||
let serialized = original.to_string();
|
||
let parsed = parse_naive_time(&serialized).unwrap();
|
||
assert_eq!(original, parsed);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// convert_val: exhaustive (Value × otyp) → Type matrix.
|
||
//
|
||
// For every (JSON Value, parser otyp) combination that can occur from
|
||
// either windmill-client SDK output (TS or Python) or a hand-written
|
||
// Postgres script, verify that convert_val returns a `(Box, Type)` pair
|
||
// where the Type matches the Box's concrete Rust type. This is the core
|
||
// invariant that makes `query_typed_raw` safe — if it ever drifts again
|
||
// (the bug introduced by #8988), users get
|
||
// `cannot convert between the Rust type X and the Postgres type Y`.
|
||
//
|
||
// We can't introspect the Box's Rust type at runtime, but we *can* feed
|
||
// each (Box, Type) through `to_sql_checked` against the asserted Type —
|
||
// that's exactly the codepath `query_typed_raw` uses, so any mismatch
|
||
// surfaces here as a `ToSql` error.
|
||
// ---------------------------------------------------------------------
|
||
|
||
use bytes::BytesMut;
|
||
use serde_json::json;
|
||
use tokio_postgres::types::IsNull;
|
||
|
||
/// Verify that convert_val for `(value, arg_t)` returns a binding whose
|
||
/// Rust type matches the asserted Postgres `Type` — exactly the check
|
||
/// `query_typed_raw` performs when serialising parameters.
|
||
///
|
||
/// Defaults to `otyp_inferred = false` (= "user explicitly typed this").
|
||
/// Tests that need the parser-default flavour use
|
||
/// `assert_convert_val_consistent_inferred`.
|
||
fn assert_convert_val_consistent(
|
||
label: &str,
|
||
value: Value,
|
||
arg_t: &str,
|
||
typ: Typ,
|
||
expected_type: Type,
|
||
) {
|
||
assert_convert_val_consistent_full(label, value, arg_t, typ, expected_type, false)
|
||
}
|
||
|
||
fn assert_convert_val_consistent_inferred(
|
||
label: &str,
|
||
value: Value,
|
||
arg_t: &str,
|
||
typ: Typ,
|
||
expected_type: Type,
|
||
) {
|
||
assert_convert_val_consistent_full(label, value, arg_t, typ, expected_type, true)
|
||
}
|
||
|
||
fn assert_convert_val_consistent_full(
|
||
label: &str,
|
||
value: Value,
|
||
arg_t: &str,
|
||
typ: Typ,
|
||
expected_type: Type,
|
||
otyp_inferred: bool,
|
||
) {
|
||
let (boxed, t) = convert_val(&value, &arg_t.to_string(), &typ, otyp_inferred)
|
||
.unwrap_or_else(|e| panic!("{label}: convert_val errored: {e}"));
|
||
assert_eq!(
|
||
t, expected_type,
|
||
"{label}: expected Type {expected_type}, got {t}"
|
||
);
|
||
// Run the encoder check — this is what query_typed_raw does internally
|
||
// when binding the param. A mismatch between the boxed Rust type and
|
||
// the asserted Type fails here as a `WrongType` error.
|
||
let mut buf = BytesMut::new();
|
||
match boxed.to_sql_checked(&t, &mut buf) {
|
||
Ok(IsNull::Yes) | Ok(IsNull::No) => {}
|
||
Err(e) => panic!(
|
||
"{label}: ToSql failed for value={value:?} arg_t={arg_t} (asserted {t}): {e}"
|
||
),
|
||
}
|
||
}
|
||
|
||
fn typ_for(arg_t: &str) -> Typ {
|
||
windmill_parser_sql::parse_pg_typ(arg_t)
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_null_for_every_known_arg_t() {
|
||
// `Value::Null` for every type the parser may resolve, plus an unknown
|
||
// arg_t (custom enum / extension). Each must produce a matching Type
|
||
// and serialise without error.
|
||
let cases: &[(&str, Type)] = &[
|
||
("bool", Type::BOOL),
|
||
("boolean", Type::BOOL),
|
||
("char", Type::CHAR),
|
||
("character", Type::CHAR),
|
||
("smallint", Type::INT2),
|
||
("int2", Type::INT2),
|
||
("smallserial", Type::INT2),
|
||
("serial2", Type::INT2),
|
||
("int", Type::INT4),
|
||
("integer", Type::INT4),
|
||
("int4", Type::INT4),
|
||
("serial", Type::INT4),
|
||
("bigint", Type::INT8),
|
||
("int8", Type::INT8),
|
||
("bigserial", Type::INT8),
|
||
("serial8", Type::INT8),
|
||
("real", Type::FLOAT4),
|
||
("float4", Type::FLOAT4),
|
||
("double", Type::FLOAT8),
|
||
("double precision", Type::FLOAT8),
|
||
("float8", Type::FLOAT8),
|
||
("numeric", Type::NUMERIC),
|
||
("decimal", Type::NUMERIC),
|
||
("oid", Type::OID),
|
||
("uuid", Type::UUID),
|
||
("date", Type::DATE),
|
||
("time", Type::TIME),
|
||
// chrono::NaiveTime can only encode as TIME — see the Null arm.
|
||
("timetz", Type::TIME),
|
||
("timestamp", Type::TIMESTAMP),
|
||
("timestamptz", Type::TIMESTAMPTZ),
|
||
("json", Type::JSON),
|
||
("jsonb", Type::JSONB),
|
||
("bytea", Type::BYTEA),
|
||
("text", Type::TEXT),
|
||
("varchar", Type::VARCHAR),
|
||
("character varying", Type::VARCHAR),
|
||
// Unknown / custom type: convert_val falls back to TEXT NULL — the
|
||
// dispatch then takes the prepare + query_raw path so the server
|
||
// resolves the actual column type.
|
||
("my_custom_enum", Type::TEXT),
|
||
];
|
||
for (arg_t, expected) in cases {
|
||
assert_convert_val_consistent(
|
||
&format!("Null/{arg_t}"),
|
||
Value::Null,
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_bool_against_every_arg_t() {
|
||
// Value::Bool. Pre-#8988 this always produced Box<bool>, which
|
||
// mismatched the asserted Type for parser-default "text" (the
|
||
// regression we fix). Post-fix:
|
||
// - explicit text-like target (`-- $1 (text)` / `$1::text`):
|
||
// coerce to Box<String>+TEXT so `WHERE text_col = $1` works.
|
||
// - parser-default text (bare `$N`, no annotation): bind as BOOL
|
||
// natively, server casts at the use site.
|
||
// - any other target: bind as BOOL.
|
||
let bool_targets = ["bool", "boolean"];
|
||
let json_targets = [("json", Type::JSON), ("jsonb", Type::JSONB)];
|
||
let explicit_text_targets = [
|
||
("text", Type::TEXT),
|
||
("varchar", Type::VARCHAR),
|
||
("character varying", Type::VARCHAR),
|
||
];
|
||
let bind_as_bool = [
|
||
"smallint",
|
||
"int",
|
||
"integer",
|
||
"bigint",
|
||
"int4",
|
||
"int8",
|
||
"real",
|
||
"double",
|
||
"double precision",
|
||
"numeric",
|
||
"uuid",
|
||
"date",
|
||
"time",
|
||
"timestamp",
|
||
"timestamptz",
|
||
"bytea",
|
||
"oid",
|
||
// unknown — server resolves via prepare path
|
||
"my_custom_enum",
|
||
];
|
||
|
||
for v in [true, false] {
|
||
for arg_t in &bool_targets {
|
||
assert_convert_val_consistent(
|
||
&format!("Bool({v})/{arg_t}"),
|
||
Value::Bool(v),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
Type::BOOL,
|
||
);
|
||
}
|
||
for (arg_t, expected) in &json_targets {
|
||
assert_convert_val_consistent(
|
||
&format!("Bool({v})/{arg_t}"),
|
||
Value::Bool(v),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
}
|
||
for (arg_t, expected) in &explicit_text_targets {
|
||
// Explicit (otyp_inferred=false): coerce to text.
|
||
assert_convert_val_consistent(
|
||
&format!("Bool({v})/{arg_t} explicit"),
|
||
Value::Bool(v),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
// Inferred (parser-default): keep BOOL.
|
||
assert_convert_val_consistent_inferred(
|
||
&format!("Bool({v})/{arg_t} inferred"),
|
||
Value::Bool(v),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
Type::BOOL,
|
||
);
|
||
}
|
||
// `char` and `character` (= bpchar) are single-byte / fixed-width
|
||
// text. Explicit decl with a JSON bool errors with an actionable
|
||
// hint instead of silently binding BOOL (which a CHAR column
|
||
// can't compare against). Inferred-default still binds BOOL.
|
||
for arg_t in &["char", "character"] {
|
||
let err = convert_val(
|
||
&Value::Bool(v),
|
||
&arg_t.to_string(),
|
||
&typ_for(arg_t),
|
||
/* otyp_inferred = */ false,
|
||
)
|
||
.err()
|
||
.unwrap_or_else(|| panic!("Bool({v})/{arg_t} explicit should error"));
|
||
let msg = err.to_string();
|
||
assert!(
|
||
msg.contains("Cannot bind a JSON bool"),
|
||
"Bool({v})/{arg_t} explicit error didn't have expected message: {msg}"
|
||
);
|
||
assert_convert_val_consistent_inferred(
|
||
&format!("Bool({v})/{arg_t} inferred"),
|
||
Value::Bool(v),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
Type::BOOL,
|
||
);
|
||
}
|
||
for arg_t in &bind_as_bool {
|
||
assert_convert_val_consistent(
|
||
&format!("Bool({v})/{arg_t}"),
|
||
Value::Bool(v),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
Type::BOOL,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_integer_number_against_every_arg_t() {
|
||
// JSON integers. Each arg_t selects its matching encoder; for arg_ts
|
||
// that don't have a numeric encoder (uuid, date, …), the value falls
|
||
// through to the generic Number arm — Box<i64> bound as INT8 — and
|
||
// server-side casts handle the rest if the SQL wants it.
|
||
let cases: &[(&str, Type)] = &[
|
||
("char", Type::CHAR),
|
||
// "character" (= bpchar in PG) doesn't have a Number arm, so it
|
||
// falls through to generic Number → Box<i64> + INT8. Server
|
||
// casts at the SQL site if the column is bpchar.
|
||
("character", Type::INT8),
|
||
("smallint", Type::INT2),
|
||
("smallserial", Type::INT2),
|
||
("int2", Type::INT2),
|
||
("serial2", Type::INT2),
|
||
("int", Type::INT4),
|
||
("integer", Type::INT4),
|
||
("int4", Type::INT4),
|
||
("serial", Type::INT4),
|
||
("bigint", Type::INT8),
|
||
("bigserial", Type::INT8),
|
||
("int8", Type::INT8),
|
||
("serial8", Type::INT8),
|
||
("oid", Type::OID),
|
||
("numeric", Type::NUMERIC),
|
||
("decimal", Type::NUMERIC),
|
||
// Unknown arg_t falls through to generic Number → INT8.
|
||
("my_custom_enum", Type::INT8),
|
||
];
|
||
for (arg_t, expected) in cases {
|
||
assert_convert_val_consistent(
|
||
&format!("Number(42)/{arg_t}"),
|
||
json!(42),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
}
|
||
// Text targets: split between explicit (coerce to TEXT) and inferred
|
||
// (parser-default, bind as INT8 — server casts at the use site).
|
||
for (arg_t, expected_text) in [("text", Type::TEXT), ("varchar", Type::VARCHAR)] {
|
||
assert_convert_val_consistent(
|
||
&format!("Number(42)/{arg_t} explicit"),
|
||
json!(42),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected_text,
|
||
);
|
||
assert_convert_val_consistent_inferred(
|
||
&format!("Number(42)/{arg_t} inferred"),
|
||
json!(42),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
Type::INT8,
|
||
);
|
||
}
|
||
// Negative integer (is_u64 false → falls to generic i64 arm for bigint).
|
||
assert_convert_val_consistent(
|
||
"Number(-7)/bigint",
|
||
json!(-7),
|
||
"bigint",
|
||
typ_for("bigint"),
|
||
Type::INT8,
|
||
);
|
||
assert_convert_val_consistent("Number(0)/oid", json!(0), "oid", typ_for("oid"), Type::OID);
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_float_number_against_every_arg_t() {
|
||
let cases: &[(&str, Type)] = &[
|
||
("real", Type::FLOAT4),
|
||
("float4", Type::FLOAT4),
|
||
("double", Type::FLOAT8),
|
||
("double precision", Type::FLOAT8),
|
||
("float8", Type::FLOAT8),
|
||
("numeric", Type::NUMERIC),
|
||
("decimal", Type::NUMERIC),
|
||
// Unknown arg_t falls through to generic → Box<f64>+FLOAT8.
|
||
("my_custom_enum", Type::FLOAT8),
|
||
];
|
||
for (arg_t, expected) in cases {
|
||
assert_convert_val_consistent(
|
||
&format!("Number(3.14)/{arg_t}"),
|
||
json!(3.14),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
}
|
||
// Text targets: split between explicit (coerce to TEXT/VARCHAR) and
|
||
// inferred (parser-default, bind as FLOAT8 — server casts at use site).
|
||
for (arg_t, expected_text) in [("text", Type::TEXT), ("varchar", Type::VARCHAR)] {
|
||
assert_convert_val_consistent(
|
||
&format!("Number(3.14)/{arg_t} explicit"),
|
||
json!(3.14),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected_text,
|
||
);
|
||
assert_convert_val_consistent_inferred(
|
||
&format!("Number(3.14)/{arg_t} inferred"),
|
||
json!(3.14),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
Type::FLOAT8,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_string_against_every_arg_t() {
|
||
// Strings parse into the matching Rust type when arg_t resolves to a
|
||
// numeric / temporal / uuid / bytea type; otherwise they bind as TEXT.
|
||
assert_convert_val_consistent(
|
||
"String('42')/smallint",
|
||
json!("42"),
|
||
"smallint",
|
||
typ_for("smallint"),
|
||
Type::INT2,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String('42')/int",
|
||
json!("42"),
|
||
"int",
|
||
typ_for("int"),
|
||
Type::INT4,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String('42')/bigint",
|
||
json!("42"),
|
||
"bigint",
|
||
typ_for("bigint"),
|
||
Type::INT8,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(uuid)/uuid",
|
||
json!("550e8400-e29b-41d4-a716-446655440000"),
|
||
"uuid",
|
||
typ_for("uuid"),
|
||
Type::UUID,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(date)/date",
|
||
json!("2024-01-15"),
|
||
"date",
|
||
typ_for("date"),
|
||
Type::DATE,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(time)/time",
|
||
json!("10:30:00"),
|
||
"time",
|
||
typ_for("time"),
|
||
Type::TIME,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(time)/timetz",
|
||
json!("10:30:00"),
|
||
"timetz",
|
||
typ_for("timetz"),
|
||
Type::TIME,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(ts)/timestamp",
|
||
json!("2024-01-15T10:30:00"),
|
||
"timestamp",
|
||
typ_for("timestamp"),
|
||
Type::TIMESTAMP,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(tstz)/timestamptz",
|
||
json!("2024-01-15T10:30:00Z"),
|
||
"timestamptz",
|
||
typ_for("timestamptz"),
|
||
Type::TIMESTAMPTZ,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"String(b64)/bytea",
|
||
json!("aGVsbG8="),
|
||
"bytea",
|
||
typ_for("bytea"),
|
||
Type::BYTEA,
|
||
);
|
||
// Generic text arms.
|
||
for (arg_t, expected) in [
|
||
("text", Type::TEXT),
|
||
("varchar", Type::VARCHAR),
|
||
("character varying", Type::VARCHAR),
|
||
// Unknown → TEXT (prepare fallback in dispatch).
|
||
("my_custom_enum", Type::TEXT),
|
||
] {
|
||
assert_convert_val_consistent(
|
||
&format!("String('hello')/{arg_t}"),
|
||
json!("hello"),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_object_against_every_arg_t() {
|
||
// Object values: bind as JSONB (default), JSON if explicitly typed,
|
||
// or JSON-stringify into TEXT/VARCHAR when arg_t is text-like.
|
||
assert_convert_val_consistent(
|
||
"Object/jsonb",
|
||
json!({"k": 1}),
|
||
"jsonb",
|
||
typ_for("jsonb"),
|
||
Type::JSONB,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Object/json",
|
||
json!({"k": 1}),
|
||
"json",
|
||
typ_for("json"),
|
||
Type::JSON,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Object/text",
|
||
json!({"k": 1}),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::TEXT,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Object/varchar",
|
||
json!({"k": 1}),
|
||
"varchar",
|
||
typ_for("varchar"),
|
||
Type::VARCHAR,
|
||
);
|
||
// Parser-default text (Typ::Str) still routes to TEXT-string via the
|
||
// `matches!(typ, Typ::Str(_))` arm.
|
||
assert_convert_val_consistent(
|
||
"Object/parser-default-text",
|
||
json!({"k": 1}),
|
||
"text",
|
||
Typ::Str(None),
|
||
Type::TEXT,
|
||
);
|
||
// Unknown arg_t parses to Typ::Str (parser's catch-all), so the
|
||
// text-coercion arm picks it up — Box<String> + TEXT. The dispatch
|
||
// then takes the prepare + query_raw path because otyp_to_pg_type
|
||
// returns Err for the unknown name, letting the server resolve the
|
||
// actual column type (e.g. a custom enum that accepts JSON via cast).
|
||
assert_convert_val_consistent(
|
||
"Object/my_custom_enum",
|
||
json!({"k": 1}),
|
||
"my_custom_enum",
|
||
typ_for("my_custom_enum"),
|
||
Type::TEXT,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn convert_val_array_against_every_arg_t() {
|
||
// arg_t with [] suffix routes to convert_vec_val.
|
||
let int_array_cases: &[(&str, Type)] = &[
|
||
("int[]", Type::INT4_ARRAY),
|
||
("integer[]", Type::INT4_ARRAY),
|
||
("int4[]", Type::INT4_ARRAY),
|
||
("smallint[]", Type::INT2_ARRAY),
|
||
("bigint[]", Type::INT8_ARRAY),
|
||
];
|
||
for (arg_t, expected) in int_array_cases {
|
||
assert_convert_val_consistent(
|
||
&format!("Array([1,2])/{arg_t}"),
|
||
json!([1, 2]),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
}
|
||
assert_convert_val_consistent(
|
||
"Array(strs)/text[]",
|
||
json!(["a", "b"]),
|
||
"text[]",
|
||
typ_for("text[]"),
|
||
Type::TEXT_ARRAY,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array(strs)/varchar[]",
|
||
json!(["a", "b"]),
|
||
"varchar[]",
|
||
typ_for("varchar[]"),
|
||
Type::VARCHAR_ARRAY,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array(bools)/bool[]",
|
||
json!([true, false]),
|
||
"bool[]",
|
||
typ_for("bool[]"),
|
||
Type::BOOL_ARRAY,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array(floats)/double[]",
|
||
json!([1.5, 2.5]),
|
||
"double[]",
|
||
typ_for("double[]"),
|
||
Type::FLOAT8_ARRAY,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array(uuids)/uuid[]",
|
||
json!(["550e8400-e29b-41d4-a716-446655440000"]),
|
||
"uuid[]",
|
||
typ_for("uuid[]"),
|
||
Type::UUID_ARRAY,
|
||
);
|
||
// `timetz[]` falls back to TIME_ARRAY for the same reason the scalar
|
||
// `timetz` falls back to TIME — chrono's `NaiveTime` only encodes for
|
||
// TIME. The encoder check (to_sql_checked) catches a mistakenly
|
||
// asserted TIMETZ_ARRAY here.
|
||
assert_convert_val_consistent(
|
||
"Array(times)/timetz[] → TIME_ARRAY",
|
||
json!(["10:30:00", "11:00:00"]),
|
||
"timetz[]",
|
||
typ_for("timetz[]"),
|
||
Type::TIME_ARRAY,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array(times)/time[]",
|
||
json!(["10:30:00", "11:00:00"]),
|
||
"time[]",
|
||
typ_for("time[]"),
|
||
Type::TIME_ARRAY,
|
||
);
|
||
// Array without [] suffix on arg_t: bind as JSONB (or JSON / TEXT).
|
||
assert_convert_val_consistent(
|
||
"Array/jsonb (no [])",
|
||
json!([1, 2, 3]),
|
||
"jsonb",
|
||
typ_for("jsonb"),
|
||
Type::JSONB,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array/json (no [])",
|
||
json!([1, 2, 3]),
|
||
"json",
|
||
typ_for("json"),
|
||
Type::JSON,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Array/text (parser-default)",
|
||
json!([1, 2, 3]),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::TEXT,
|
||
);
|
||
// See Object/my_custom_enum: Typ::Str catch-all → TEXT-stringify;
|
||
// dispatch falls back to prepare for the unknown arg_t.
|
||
assert_convert_val_consistent(
|
||
"Array/my_custom_enum",
|
||
json!([1, 2, 3]),
|
||
"my_custom_enum",
|
||
typ_for("my_custom_enum"),
|
||
Type::TEXT,
|
||
);
|
||
// NULL-array shape: Value::Null with arg_t ending in []
|
||
assert_convert_val_consistent(
|
||
"Null/int[]",
|
||
Value::Null,
|
||
"int[]",
|
||
typ_for("int[]"),
|
||
Type::INT4_ARRAY,
|
||
);
|
||
}
|
||
|
||
/// Edge cases mirroring what the SDKs (TS / Python) and hand-written PG
|
||
/// scripts can actually emit. Each case is a real input → encode round
|
||
/// trip, and would have failed under #8988 if the asserted Type drifted
|
||
/// from the binding's Rust type.
|
||
#[test]
|
||
fn convert_val_sdk_edge_cases() {
|
||
// TS SDK shapes — `${val}` is auto-tagged with ::TYPE.
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${42}",
|
||
json!(42),
|
||
"bigint",
|
||
typ_for("bigint"),
|
||
Type::INT8,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${3.14}",
|
||
json!(3.14),
|
||
"double",
|
||
typ_for("double"),
|
||
Type::FLOAT8,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${true}",
|
||
json!(true),
|
||
"boolean",
|
||
typ_for("boolean"),
|
||
Type::BOOL,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${\"hello\"}",
|
||
json!("hello"),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::TEXT,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${{x:1}}",
|
||
json!({"x": 1}),
|
||
"json",
|
||
typ_for("json"),
|
||
Type::JSON,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${[1,2,3]}",
|
||
json!([1, 2, 3]),
|
||
"json",
|
||
typ_for("json"),
|
||
Type::JSON,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"TS SDK ${null}",
|
||
Value::Null,
|
||
"text",
|
||
typ_for("text"),
|
||
Type::TEXT,
|
||
);
|
||
|
||
// CAST(${val} AS T) shape — the SDK strips its own ::TYPE here, so
|
||
// the parser sees a bare $N and otyp defaults to "text" *with
|
||
// otyp_inferred = true*. This is the original regression #8988
|
||
// introduced; the inferred-default flag is what lets convert_val
|
||
// bind the value's natural type rather than coerce to TEXT.
|
||
assert_convert_val_consistent_inferred(
|
||
"CAST AS bool / Bool true",
|
||
json!(true),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::BOOL,
|
||
);
|
||
assert_convert_val_consistent_inferred(
|
||
"CAST AS bool / Bool false",
|
||
json!(false),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::BOOL,
|
||
);
|
||
// Object falls into the text-coercion arm (Object branch checks
|
||
// `matches!(typ, Typ::Str(_))` regardless of otyp_inferred — JSON
|
||
// serialisation is always safer than asserting JSONB for an
|
||
// unannotated arg).
|
||
assert_convert_val_consistent_inferred(
|
||
"CAST AS jsonb / Object",
|
||
json!({"a": 1, "b": [2, 3]}),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::TEXT,
|
||
);
|
||
assert_convert_val_consistent_inferred(
|
||
"CAST AS int / Number",
|
||
json!(7),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::INT8,
|
||
);
|
||
|
||
// Python SDK datatable shape — type sits in the declaration comment
|
||
// (`-- $1 arg1 (BIGINT)`). Parser resolves otyp before convert_val.
|
||
assert_convert_val_consistent(
|
||
"Python decl bigint / Number",
|
||
json!(42),
|
||
"bigint",
|
||
typ_for("bigint"),
|
||
Type::INT8,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Python decl text / String",
|
||
json!("hello"),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::TEXT,
|
||
);
|
||
assert_convert_val_consistent(
|
||
"Python decl jsonb / Object",
|
||
json!({"k": [1, 2]}),
|
||
"jsonb",
|
||
typ_for("jsonb"),
|
||
Type::JSONB,
|
||
);
|
||
|
||
// Mismatched-but-coercible JSON shape: JSON int 0/1 into a bool col
|
||
// still works because tokio_postgres encodes Number as INT8 and
|
||
// postgres has int→bool cast at the SQL site. Uses the inferred
|
||
// path (parser-default text otyp).
|
||
assert_convert_val_consistent_inferred(
|
||
"Number(0)/bool (parser default)",
|
||
json!(0),
|
||
"text",
|
||
typ_for("text"),
|
||
Type::INT8,
|
||
);
|
||
}
|
||
|
||
/// `decimal_fits_f64_losslessly` returns true for values that round-trip
|
||
/// through f64 and false for values that don't. This is the predicate
|
||
/// behind the one-shot precision-loss warning.
|
||
#[test]
|
||
fn decimal_fits_f64_losslessly_predicate() {
|
||
use std::str::FromStr;
|
||
// Values that fit f64 cleanly:
|
||
for s in &["0", "1", "-1", "3.14", "1234.5", "-0.5", "10000000000"] {
|
||
let d = Decimal::from_str(s).unwrap();
|
||
assert!(
|
||
decimal_fits_f64_losslessly(&d),
|
||
"expected `{s}` to fit f64 losslessly"
|
||
);
|
||
}
|
||
// Values past f64's ~15 significant-digit window lose precision:
|
||
for s in &[
|
||
"12345678901234.56789", // 19 sig digits
|
||
"0.123456789012345678", // 18 sig digits past the decimal
|
||
"99999999999999999999", // 20-digit integer
|
||
] {
|
||
let d = Decimal::from_str(s).unwrap();
|
||
assert!(
|
||
!decimal_fits_f64_losslessly(&d),
|
||
"expected `{s}` to NOT fit f64 losslessly"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `should_check_precision` returns `true` exactly
|
||
/// `NUMERIC_PRECISION_CHECK_BUDGET` times, then `false` forever — and
|
||
/// `false` immediately once the precision-loss flag has been set, so the
|
||
/// hot path on a numeric-heavy result set is one cheap atomic load after
|
||
/// the first lossy value is observed.
|
||
#[test]
|
||
fn precision_check_budget_caps_per_query_overhead() {
|
||
use std::sync::atomic::Ordering;
|
||
let state = ResultFormatState::default();
|
||
let mut allowed = 0u32;
|
||
let mut denied = 0u32;
|
||
for _ in 0..(NUMERIC_PRECISION_CHECK_BUDGET + 100) {
|
||
if state.should_check_precision() {
|
||
allowed += 1;
|
||
} else {
|
||
denied += 1;
|
||
}
|
||
}
|
||
assert_eq!(allowed, NUMERIC_PRECISION_CHECK_BUDGET);
|
||
assert_eq!(denied, 100);
|
||
// The flag short-circuits the budget — once set, no more checks run
|
||
// even if the budget hadn't been spent.
|
||
let state = ResultFormatState::default();
|
||
state.numeric_precision_loss.store(true, Ordering::Relaxed);
|
||
for _ in 0..10 {
|
||
assert!(!state.should_check_precision());
|
||
}
|
||
// Budget untouched.
|
||
assert_eq!(
|
||
state.numeric_precision_check_budget.load(Ordering::Relaxed),
|
||
NUMERIC_PRECISION_CHECK_BUDGET
|
||
);
|
||
}
|
||
|
||
/// Sparse positional placeholders renumber to a contiguous 1..=N without
|
||
/// substring collisions OR mangling string-literal/comment occurrences.
|
||
/// The pre-existing `String::replace` chain turned `$50` into `$10` when
|
||
/// oidx=5 was processed first; even the regex-with-greedy-digits approach
|
||
/// (a regression of its own) walked through string literals. The current
|
||
/// position-aware rewrite uses the parser's tokenizer to skip those.
|
||
#[test]
|
||
fn renumber_sparse_placeholders_no_collision_no_string_mangling() {
|
||
fn renumber(input: &str, mapping: &HashMap<i32, usize>) -> String {
|
||
let mut out = input.to_owned();
|
||
let mut positions = windmill_parser_sql::parse_pg_statement_arg_positions(input);
|
||
positions.sort_by_key(|(_, range)| std::cmp::Reverse(range.start));
|
||
for (oidx, range) in positions {
|
||
if let Some(new_i) = mapping.get(&oidx) {
|
||
if oidx as usize != *new_i {
|
||
out.replace_range(range, &new_i.to_string());
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
let mapping: HashMap<i32, usize> = [(5, 1), (50, 2)].into_iter().collect();
|
||
let cases = &[
|
||
// Two placeholders, full rewrite (greedy-digit collision check).
|
||
("SELECT $5, $50", "SELECT $1, $2"),
|
||
// Same input flipped — order independence.
|
||
("SELECT $50, $5", "SELECT $2, $1"),
|
||
// Repeat use of an index — every site gets rewritten.
|
||
(
|
||
"SELECT $5 FROM t WHERE id = $5 OR ref = $50",
|
||
"SELECT $1 FROM t WHERE id = $1 OR ref = $2",
|
||
),
|
||
// Index outside the mapping is left intact.
|
||
("SELECT $5, $99", "SELECT $1, $99"),
|
||
// String literal containing the same `$N` syntax must not be
|
||
// rewritten — the tokenizer marks it as inside a string.
|
||
(
|
||
"SELECT 'price: $5' AS lbl, $5 FROM t",
|
||
"SELECT 'price: $5' AS lbl, $1 FROM t",
|
||
),
|
||
// Single-line comment must not be rewritten either.
|
||
("-- mention $5\nSELECT $5", "-- mention $5\nSELECT $1"),
|
||
// Dollar-quoted block ($$ … $$) must not be rewritten.
|
||
("SELECT $$body with $5$$, $5", "SELECT $$body with $5$$, $1"),
|
||
];
|
||
for (input, expected) in cases {
|
||
assert_eq!(
|
||
renumber(input, &mapping).as_str(),
|
||
*expected,
|
||
"input={input}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Drift-prevention: `otyp_to_pg_type` and `convert_val` must agree on the
|
||
/// Type for every recognised arg_t when the JSON value matches the arg_t's
|
||
/// "natural" Rust kind. Fails if someone adds a new arg_t to one but not
|
||
/// the other, or changes the Type returned by either.
|
||
#[test]
|
||
fn otyp_to_pg_type_and_convert_val_agree_for_recognised_types() {
|
||
// (arg_t, natural-value, expected scalar Type)
|
||
let cases: &[(&str, Value, Type)] = &[
|
||
("bool", json!(true), Type::BOOL),
|
||
("boolean", json!(false), Type::BOOL),
|
||
("char", json!(65), Type::CHAR),
|
||
("smallint", json!(1), Type::INT2),
|
||
("smallserial", json!(1), Type::INT2),
|
||
("int2", json!(1), Type::INT2),
|
||
("serial2", json!(1), Type::INT2),
|
||
("int", json!(1), Type::INT4),
|
||
("integer", json!(1), Type::INT4),
|
||
("int4", json!(1), Type::INT4),
|
||
("serial", json!(1), Type::INT4),
|
||
("bigint", json!(1), Type::INT8),
|
||
("int8", json!(1), Type::INT8),
|
||
("bigserial", json!(1), Type::INT8),
|
||
("serial8", json!(1), Type::INT8),
|
||
("real", json!(1.5), Type::FLOAT4),
|
||
("float4", json!(1.5), Type::FLOAT4),
|
||
("double", json!(1.5), Type::FLOAT8),
|
||
("double precision", json!(1.5), Type::FLOAT8),
|
||
("float8", json!(1.5), Type::FLOAT8),
|
||
("numeric", json!(1), Type::NUMERIC),
|
||
("decimal", json!(1), Type::NUMERIC),
|
||
("oid", json!(1), Type::OID),
|
||
(
|
||
"uuid",
|
||
json!("550e8400-e29b-41d4-a716-446655440000"),
|
||
Type::UUID,
|
||
),
|
||
("date", json!("2024-01-15"), Type::DATE),
|
||
("time", json!("10:30:00"), Type::TIME),
|
||
// chrono::NaiveTime can only encode TIME — see the timetz arm.
|
||
("timetz", json!("10:30:00"), Type::TIME),
|
||
("timestamp", json!("2024-01-15T10:30:00"), Type::TIMESTAMP),
|
||
(
|
||
"timestamptz",
|
||
json!("2024-01-15T10:30:00Z"),
|
||
Type::TIMESTAMPTZ,
|
||
),
|
||
("json", json!({"k": 1}), Type::JSON),
|
||
("jsonb", json!({"k": 1}), Type::JSONB),
|
||
("bytea", json!("aGVsbG8="), Type::BYTEA),
|
||
("text", json!("hello"), Type::TEXT),
|
||
("varchar", json!("hello"), Type::VARCHAR),
|
||
("character varying", json!("hello"), Type::VARCHAR),
|
||
];
|
||
for (arg_t, value, expected) in cases {
|
||
// 1. The dispatch's "is recognised" gate must accept this arg_t.
|
||
// `timetz` is the one exception where we deliberately return
|
||
// TIME from convert_val (chrono limitation), but otyp_to_pg_type
|
||
// returns TIMETZ.
|
||
let from_otyp = otyp_to_pg_type(arg_t)
|
||
.unwrap_or_else(|e| panic!("otyp_to_pg_type lost arg_t `{arg_t}`: {e}"));
|
||
if *arg_t != "timetz" {
|
||
assert_eq!(
|
||
from_otyp, *expected,
|
||
"otyp_to_pg_type({arg_t}) drift: expected {expected}, got {from_otyp}"
|
||
);
|
||
}
|
||
// 2. convert_val must produce a binding whose Type matches
|
||
// `expected`, AND whose Rust type successfully encodes against
|
||
// that Type (the to_sql_checked round-trip).
|
||
assert_convert_val_consistent(
|
||
&format!("meta/{arg_t}"),
|
||
value.clone(),
|
||
arg_t,
|
||
typ_for(arg_t),
|
||
expected.clone(),
|
||
);
|
||
}
|
||
}
|
||
}
|