Skip to main content

spg_engine/
describe.rs

1//! v6.3.3 — Describe statement pre-Execute.
2//!
3//! Given a `Statement` returned by `Engine::prepare()`, compute
4//! `(parameter_oids, output_columns)` without executing the
5//! statement.
6//!
7//! Implementation policy:
8//! - `parameter_oids`: count distinct `$N` placeholders in the AST
9//!   and return a Vec<u32> of zeros (oid=0 = "let the server infer
10//!   at Bind time"). PG drivers happily accept this.
11//! - `output_columns`: resolve the FROM namespace — table, view,
12//!   CTE, derived table, and every joined relation — then describe
13//!   each SELECT item against it. Anything that cannot be resolved
14//!   collapses the whole list to empty, which the pgwire layer maps
15//!   to a `NoData` reply.
16//!
17//! v7.39 (round 462) — "complex shapes degrade to NoData, which
18//! drivers tolerate" was wrong, and the cost was silent.
19//!
20//! Execute never sends a RowDescription (Describe owns it), so a
21//! shape Describe cannot resolve reaches an extended-protocol client
22//! as data rows with NO column metadata at all. Measured against
23//! PG18 over sqlx: a view, a JOIN, a derived table, a UNION, a CTE
24//! and every system catalog view all declared zero columns, so
25//! `row.get(0)` was out of bounds on rows that plainly carried
26//! values. Only a bare single-table SELECT worked. PG18 declares all
27//! of them.
28//!
29//! Describe and execution must therefore agree by construction —
30//! [`crate::tests`] pins the two against each other over a shape
31//! corpus so a future shape cannot drift the way views did.
32
33use alloc::string::{String, ToString};
34use alloc::vec::Vec;
35
36use spg_sql::ast::{Expr, Literal, SelectItem, SelectStatement, Statement, UnOp};
37use spg_storage::{Catalog, ColumnSchema, DataType, Value};
38
39/// One-shot describe of a prepared `Statement`.
40///
41/// Returns `(parameter_oids, output_columns)`. Empty `output_columns`
42/// means "no row description available" → pgwire sends NoData.
43pub fn describe_prepared(stmt: &Statement, catalog: &Catalog) -> (Vec<u32>, Vec<ColumnSchema>) {
44    let params = collect_parameter_oids(stmt, catalog);
45    let columns = describe_output_columns(stmt, catalog);
46    (params, columns)
47}
48
49/// A relation chain deep enough to hit this is either pathological or
50/// a cycle the catalog should not contain; stop rather than recurse.
51const MAX_DESCRIBE_DEPTH: usize = 16;
52
53fn describe_output_columns(stmt: &Statement, catalog: &Catalog) -> Vec<ColumnSchema> {
54    let Statement::Select(s) = stmt else {
55        return Vec::new();
56    };
57    describe_select_columns(s, catalog, &[], 0)
58}
59
60/// Output columns of one SELECT, resolved against `catalog` plus any
61/// CTEs already in scope from an enclosing query.
62pub(crate) fn describe_select_columns(
63    s: &SelectStatement,
64    catalog: &Catalog,
65    outer_ctes: &[&spg_sql::ast::Cte],
66    depth: usize,
67) -> Vec<ColumnSchema> {
68    if depth > MAX_DESCRIBE_DEPTH {
69        return Vec::new();
70    }
71    // A CTE shadows an outer binding of the same name, so this query's
72    // own list goes first — `relation_columns` takes the first match.
73    let mut ctes: Vec<&spg_sql::ast::Cte> = s.ctes.iter().collect();
74    ctes.extend(outer_ctes.iter());
75
76    // No FROM (`SELECT 1::INT AS one`) → describe items against an
77    // empty namespace; literal / cast / function items still resolve.
78    let ns = match &s.from {
79        None => Vec::new(),
80        Some(from) => {
81            let Some(mut ns) = relation_columns(&from.primary, catalog, &ctes, depth) else {
82                return Vec::new();
83            };
84            for j in &from.joins {
85                let Some(cols) = relation_columns(&j.table, catalog, &ctes, depth) else {
86                    return Vec::new();
87                };
88                ns.extend(cols);
89            }
90            ns
91        }
92    };
93    // `t.*` over a multi-relation FROM cannot be answered from the flat
94    // namespace — it carries no record of which columns came from which
95    // relation, so expanding it would describe every column instead of
96    // t's. NoData is the honest answer until the namespace is keyed.
97    if s.from.as_ref().is_some_and(|f| !f.joins.is_empty())
98        && s.items
99            .iter()
100            .any(|i| matches!(i, SelectItem::QualifiedWildcard(_)))
101    {
102        return Vec::new();
103    }
104    let out = describe_select_items(&s.items, &ns);
105    if out.is_empty() {
106        return out;
107    }
108    // A set operation takes its column NAMES from the first arm, so
109    // `out` is already right — but only if the arms actually line up.
110    // A width disagreement means the query will fail at execution;
111    // describing it as if it succeeded would be worse than NoData.
112    for (_, arm) in &s.unions {
113        if describe_select_columns(arm, catalog, &ctes, depth + 1).len() != out.len() {
114            return Vec::new();
115        }
116    }
117    out
118}
119
120/// Columns one FROM entry contributes to the namespace, or `None` when
121/// the relation cannot be resolved (the caller then reports NoData
122/// rather than describing a partial namespace).
123fn relation_columns(
124    t: &spg_sql::ast::TableRef,
125    catalog: &Catalog,
126    ctes: &[&spg_sql::ast::Cte],
127    depth: usize,
128) -> Option<Vec<ColumnSchema>> {
129    // A derived table (`FROM (SELECT …) x`, LATERAL or not) rides the
130    // lateral_subquery channel.
131    if let Some(sub) = &t.lateral_subquery {
132        let cols = describe_select_columns(sub, catalog, &[], depth + 1);
133        return (!cols.is_empty()).then_some(cols);
134    }
135    if let Some(table) = catalog.get(&t.name) {
136        return Some(table.schema().columns.clone());
137    }
138    if let Some(cte) = ctes.iter().find(|c| c.name == t.name) {
139        let spg_sql::ast::CteBody::Select(body) = &cte.body else {
140            // A data-modifying CTE is described by its RETURNING list,
141            // which needs the modifying statement's own describe path.
142            return None;
143        };
144        let mut cols = describe_select_columns(body, catalog, &[], depth + 1);
145        if cols.is_empty() {
146            return None;
147        }
148        // `WITH name(a, b, c)` renames positionally.
149        if !cte.column_overrides.is_empty() && cte.column_overrides.len() == cols.len() {
150            for (slot, name) in cols.iter_mut().zip(cte.column_overrides.iter()) {
151                slot.name = name.clone();
152            }
153        }
154        return Some(cols);
155    }
156    if catalog.has_view(&t.name) {
157        let cols = describe_view_columns_depth(catalog, &t.name, depth + 1);
158        return (!cols.is_empty()).then_some(cols);
159    }
160    // UNNEST / VALUES / anything else the executor synthesises has no
161    // catalog shape to read here.
162    None
163}
164
165fn describe_select_items(items: &[SelectItem], schema_cols: &[ColumnSchema]) -> Vec<ColumnSchema> {
166    let mut out: Vec<ColumnSchema> = Vec::with_capacity(items.len());
167    for item in items {
168        match item {
169            // A qualified wildcard over a single relation describes the
170            // same as `*`; the multi-relation case is refused above.
171            SelectItem::Wildcard | SelectItem::QualifiedWildcard(_) => {
172                for c in schema_cols {
173                    out.push(c.clone());
174                }
175            }
176            SelectItem::Expr { expr, alias } => {
177                let Some(desc) = describe_expr(expr, schema_cols) else {
178                    return Vec::new();
179                };
180                let name = alias.clone().unwrap_or(desc.name);
181                out.push(ColumnSchema {
182                    collation_name: None,
183                    user_composite_type: None,
184                    acl: alloc::vec::Vec::new(),
185                    name,
186                    ty: desc.ty,
187                    nullable: desc.nullable,
188                    auto_increment: false,
189                    default: None,
190                    runtime_default: None,
191                    user_enum_type: None,
192                    user_domain_type: None,
193                    on_update_runtime: None,
194                    collation: spg_storage::Collation::Binary,
195                    is_unsigned: false,
196                    inline_enum_variants: None,
197                    inline_set_variants: None,
198                    generated_stored_expr: None,
199                    identity_always: false,
200                    default_text: None,
201                    auto_restart: None,
202                    scalar_row_source: false,
203                    mysql_int_width: None,
204                    mysql_fsp: None,
205                });
206            }
207        }
208    }
209    out
210}
211
212/// v7.39 (round 268) — a view's output columns, resolved from its
213/// stored body. `information_schema.columns` had no rows at all for a
214/// view before this, so a reflection tool saw every view as a relation
215/// with no columns.
216///
217/// The namespace a view body resolves against is wider than the
218/// prepared-statement describe path builds: the primary may itself be a
219/// view (recurse), and joined tables contribute their columns too. An
220/// item that cannot be resolved collapses the whole list to empty,
221/// which the caller reports as "no columns known" rather than guessing.
222pub(crate) fn describe_view_columns(catalog: &Catalog, view_name: &str) -> Vec<ColumnSchema> {
223    describe_view_columns_depth(catalog, view_name, 0)
224}
225
226fn describe_view_columns_depth(
227    catalog: &Catalog,
228    view_name: &str,
229    depth: usize,
230) -> Vec<ColumnSchema> {
231    if depth > MAX_DESCRIBE_DEPTH {
232        return Vec::new();
233    }
234    let Some(view) = catalog.view(view_name) else {
235        return Vec::new();
236    };
237    let Ok(Statement::Select(select)) = spg_sql::parser::parse_statement(&view.body) else {
238        return Vec::new();
239    };
240    // v7.39 (round 462) — the body resolves through the same namespace
241    // walk a top-level SELECT uses, so a view over a join, a derived
242    // table or a CTE describes exactly as that query would.
243    let mut out = describe_select_columns(&select, catalog, &[], depth);
244    // A rename list overrides the body's own names, positionally.
245    if !view.columns.is_empty() && view.columns.len() == out.len() {
246        for (slot, name) in out.iter_mut().zip(view.columns.iter()) {
247            slot.name = name.clone();
248        }
249    }
250    // Every column of a view is nullable in PG, even where the base
251    // column is NOT NULL: the view's rows are a query result, and PG
252    // does not carry the base constraint through.
253    for c in &mut out {
254        c.nullable = true;
255    }
256    out
257}
258
259pub(crate) struct ExprShape {
260    pub(crate) name: String,
261    pub(crate) ty: DataType,
262    pub(crate) nullable: bool,
263}
264
265/// v7.38 (read01) — PG numeric-category width rank for common-type
266/// resolution: smallint < int < bigint < numeric < double precision.
267/// `real` (float4) is deliberately omitted: PG's preferred-type rules make
268/// some real mixes resolve to `real` and others to `double precision`, so a
269/// real-involving mix is left uncoerced rather than risk the wrong widening.
270pub(crate) fn numeric_rank(t: DataType) -> Option<u8> {
271    match t {
272        DataType::SmallInt => Some(1),
273        DataType::Int => Some(2),
274        DataType::BigInt => Some(3),
275        DataType::Numeric { .. } => Some(4),
276        // v7.39 (round 649) — rank 5 was left empty for `real` and never
277        // filled, so any sibling set containing one failed the
278        // "all numeric" test and fell through untouched. Measured:
279        // `coalesce(1::real, 1.5::float8)` answered `real` where PG says
280        // `double precision`, and `coalesce(1::int, 1::real)` answered
281        // `integer` where PG says `real`.
282        DataType::Real => Some(5),
283        DataType::Float => Some(6),
284        _ => None,
285    }
286}
287
288/// v7.38 (read01) — the PG common type for a set of sibling branch/argument
289/// types (`CASE`, `COALESCE`, `GREATEST`/`LEAST`, `NULLIF`). A safe subset of
290/// PG's type resolution; returns `None` for anything ambiguous so the caller
291/// leaves the value untouched (never turns a working expression into one that
292/// coerces wrongly or errors):
293///   * all numeric-category → the widest (int ∪ numeric → numeric,
294///     … ∪ float8 → float8);
295///   * DATE/TIMESTAMP/TIMESTAMPTZ with at least one timestamp[tz] →
296///     timestamptz if any is tz, else timestamp (all are the same UTC
297///     instant, so widening is lossless);
298///   * exactly one concrete non-TEXT type mixed with TEXT → that type.
299pub(crate) fn common_type(types: &[DataType]) -> Option<DataType> {
300    let mut distinct: Vec<&DataType> = Vec::new();
301    for t in types {
302        if !distinct.iter().any(|d| *d == t) {
303            distinct.push(t);
304        }
305    }
306    if distinct.len() < 2 {
307        return None;
308    }
309    if distinct.iter().all(|t| numeric_rank(**t).is_some()) {
310        return distinct
311            .iter()
312            .max_by_key(|t| numeric_rank(***t).unwrap_or(0))
313            .map(|t| *(*t));
314    }
315    let non_text: Vec<&DataType> = distinct
316        .iter()
317        .copied()
318        .filter(|t| !matches!(t, DataType::Text))
319        .collect();
320    if non_text.iter().all(|t| {
321        matches!(
322            t,
323            DataType::Date | DataType::Timestamp | DataType::Timestamptz
324        )
325    }) && non_text
326        .iter()
327        .any(|t| matches!(t, DataType::Timestamp | DataType::Timestamptz))
328    {
329        if non_text.iter().any(|t| matches!(t, DataType::Timestamptz)) {
330            return Some(DataType::Timestamptz);
331        }
332        return Some(DataType::Timestamp);
333    }
334    if non_text.len() == 1 {
335        return Some(*non_text[0]);
336    }
337    None
338}
339
340/// v7.39 (round 609) — a literal's type and nullability, split out so the
341/// type-only entry point below reads it without building a shape name.
342fn literal_type(lit: &spg_sql::ast::Literal) -> Option<(DataType, bool)> {
343    use spg_sql::ast::Literal as L;
344    let (ty, nullable) = match lit {
345        L::Null => (DataType::Text, true),
346        // Array literals only enter the AST via the
347        // prepared-bind path; surface as TEXT (no array
348        // DataType in the describe surface yet).
349        L::TextArray(_) | L::IntArray(_) | L::BigIntArray(_) => (DataType::Text, false),
350        // PG-canonical literal-int typing: `pg_typeof(1) =
351        // integer`, `pg_typeof(2147483648) = bigint`. The
352        // engine's runtime Value::Int(i32) flows naturally
353        // into INT columns; widening to BIGINT happens in
354        // coerce_value only when the column type asks for
355        // it. Bisected to P0-4: pre-fix every literal was
356        // BigInt, which let `WITH RECURSIVE t(n) AS (SELECT
357        // 1 …)` infer the working table column as BIGINT
358        // while the second-iteration INSERT path produced a
359        // Value::Int(1) — type mismatch.
360        L::Integer(n) => {
361            if i32::try_from(*n).is_ok() {
362                (DataType::Int, false)
363            } else {
364                (DataType::BigInt, false)
365            }
366        }
367        L::Float(_) => (DataType::Float, false),
368        L::Numeric { .. } => (
369            DataType::Numeric {
370                precision: 0,
371                scale: 0,
372            },
373            false,
374        ),
375        L::NumericBig(_) => (
376            DataType::Numeric {
377                precision: 0,
378                scale: 0,
379            },
380            false,
381        ),
382        L::String(_) => (DataType::Text, false),
383        L::Bool(_) => (DataType::Bool, false),
384        L::Vector(_) | L::Interval { .. } => return None,
385    };
386    Some((ty, nullable))
387}
388
389/// v7.39 (round 609) — the TYPE alone, without building the shape's name.
390///
391/// `unify_branch_types_static` runs for every row of a COALESCE / GREATEST /
392/// LEAST and only ever reads `.ty`, but `describe_expr` clones a column's
393/// name (or writes "?column?") to hand it back: two allocations a row for
394/// `coalesce(id, 0)`, whose answer needs none.
395pub(crate) fn describe_expr_type(e: &Expr, schema_cols: &[ColumnSchema]) -> Option<DataType> {
396    match e {
397        Expr::Column(c) => {
398            // The same lookup `describe_expr` does, minus the name clone.
399            if let Some(col) = schema_cols.iter().find(|s| s.name == c.name) {
400                return Some(col.ty);
401            }
402            describe_expr(e, schema_cols).map(|s| s.ty)
403        }
404        Expr::Literal(lit) => literal_type(lit).map(|(ty, _)| ty),
405        _ => describe_expr(e, schema_cols).map(|s| s.ty),
406    }
407}
408
409pub(crate) fn describe_expr(e: &Expr, schema_cols: &[ColumnSchema]) -> Option<ExprShape> {
410    match e {
411        Expr::Column(c) => {
412            // Mirror resolve_projection_column's lookup: bare name first,
413            // then qualified-prefix match.
414            let bare = schema_cols.iter().find(|s| s.name == c.name);
415            if let Some(col) = bare {
416                return Some(ExprShape {
417                    name: c.name.clone(),
418                    ty: col.ty,
419                    nullable: col.nullable,
420                });
421            }
422            let suffix = alloc::format!(".{}", c.name);
423            let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
424            let first = matches.next()?;
425            if matches.next().is_some() {
426                // ambiguous — bail (describe should not assume an
427                // arbitrary tiebreak)
428                return None;
429            }
430            Some(ExprShape {
431                name: c.name.clone(),
432                ty: first.ty,
433                nullable: first.nullable,
434            })
435        }
436        Expr::Literal(lit) => {
437            let (ty, nullable) = literal_type(lit)?;
438            Some(ExprShape {
439                name: "?column?".to_string(),
440                ty,
441                nullable,
442            })
443        }
444        Expr::Cast { target, .. } => {
445            use spg_sql::ast::CastTarget;
446            let ty = match target {
447                CastTarget::Int => DataType::Int,
448                CastTarget::BigInt => DataType::BigInt,
449                CastTarget::Float => DataType::Float,
450                CastTarget::Text => DataType::Text,
451                CastTarget::Bool => DataType::Bool,
452                CastTarget::Vector => return None,
453                CastTarget::Date => DataType::Date,
454                CastTarget::Timestamp => DataType::Timestamp,
455                CastTarget::Timestamptz => DataType::Timestamptz,
456                CastTarget::Interval => DataType::Interval,
457                CastTarget::Json => DataType::Json,
458                CastTarget::Jsonb => DataType::Jsonb,
459                // regtype / regclass yield text-shape catalog OIDs
460                // on PG; on SPG the engine surfaces Unsupported,
461                // but for describe we still claim Text so prepare
462                // doesn't fail.
463                CastTarget::RegType | CastTarget::RegClass => DataType::Text,
464                CastTarget::TextArray => DataType::TextArray,
465                CastTarget::IntArray => DataType::IntArray,
466                CastTarget::BigIntArray => DataType::BigIntArray,
467                // v7.12.0 — `::tsvector` / `::tsquery`.
468                CastTarget::TsVector => DataType::TsVector,
469                CastTarget::TsQuery => DataType::TsQuery,
470                CastTarget::Uuid => DataType::Uuid,
471                CastTarget::Bytea => DataType::Bytes,
472                // v7.37.5 — generic typed-cast escape. Resolve the
473                // ident to a `DataType` for prepare-time schema
474                // information; truly-unknown idents bail (describe
475                // returns None so prepare reports an Unsupported).
476                CastTarget::Named(name) => crate::conversions::type_name_to_data_type(name)?,
477            };
478            Some(ExprShape {
479                name: "?column?".to_string(),
480                ty,
481                nullable: true,
482            })
483        }
484        // Unary minus preserves the operand's type.
485        Expr::Unary {
486            op: UnOp::Neg,
487            expr,
488        } => {
489            let inner = describe_expr(expr, schema_cols)?;
490            Some(ExprShape {
491                name: "?column?".to_string(),
492                ty: inner.ty,
493                nullable: inner.nullable,
494            })
495        }
496        // Function call — dispatch on name to recover the column
497        // type that the wire layer (and sqlx::Column type_info)
498        // advertises. Without this entry build_projection falls
499        // back to `Text` for every non-trivial expression, which
500        // breaks `sqlx::query_as::<_, (chrono::NaiveDateTime,)>(
501        // "SELECT now()")` and every similar typed-decode pattern.
502        Expr::FunctionCall { name, args } => function_return_shape(name, args, schema_cols),
503        // v7.26 (round-20 C) — aggregate modifiers delegate to the
504        // inner call (DISTINCT / internal ORDER BY don't change the
505        // output type).
506        Expr::AggregateOrdered { call, .. } => describe_expr(call, schema_cols),
507        // v7.39 (round 268) — a window call. The pure window functions
508        // have fixed result types (measured on PG 18.4); everything else
509        // over OVER() is an aggregate and keeps the aggregate's type, so
510        // it delegates. Without this arm a view with any window column
511        // resolved to nothing at all, and reported no columns.
512        Expr::WindowFunction { name, args, .. } => {
513            let lower = name.to_ascii_lowercase();
514            let fixed = match lower.as_str() {
515                "row_number" | "rank" | "dense_rank" => Some(DataType::BigInt),
516                "ntile" => Some(DataType::Int),
517                "percent_rank" | "cume_dist" => Some(DataType::Float),
518                _ => None,
519            };
520            if let Some(ty) = fixed {
521                return Some(ExprShape {
522                    name: lower,
523                    ty,
524                    nullable: true,
525                });
526            }
527            // lag / lead / first_value / last_value / nth_value report
528            // their first argument's type.
529            if matches!(
530                lower.as_str(),
531                "lag" | "lead" | "first_value" | "last_value" | "nth_value"
532            ) {
533                let inner = describe_expr(args.first()?, schema_cols)?;
534                return Some(ExprShape {
535                    name: lower,
536                    ty: inner.ty,
537                    nullable: true,
538                });
539            }
540            let inner = function_return_shape(name, args, schema_cols)?;
541            Some(ExprShape {
542                name: lower,
543                ty: inner.ty,
544                nullable: true,
545            })
546        }
547        // CASE — unify on the first THEN branch's shape (PG unifies
548        // across branches; first-branch is the pragmatic subset).
549        Expr::Case {
550            branches,
551            else_branch,
552            ..
553        } => {
554            let probe = branches
555                .first()
556                .map(|(_, t)| t)
557                .or(else_branch.as_deref())?;
558            let inner = describe_expr(probe, schema_cols)?;
559            Some(ExprShape {
560                name: "case".to_string(),
561                ty: inner.ty,
562                nullable: true,
563            })
564        }
565        // Binary — comparisons/logic → BOOL; everything else takes
566        // the left operand's shape (PG's numeric promotion is finer,
567        // but lhs covers the aggregate/projection metadata cases).
568        Expr::Binary { lhs, op, rhs: _ } => {
569            use spg_sql::ast::BinOp as B;
570            match op {
571                B::Eq | B::NotEq | B::Lt | B::LtEq | B::Gt | B::GtEq | B::And | B::Or => {
572                    Some(ExprShape {
573                        name: "?column?".to_string(),
574                        ty: DataType::Bool,
575                        nullable: true,
576                    })
577                }
578                _ => {
579                    let inner = describe_expr(lhs, schema_cols)?;
580                    // v7.38 (read01 A-bitcat) — PG's `||` on bit strings is
581                    // `bitcat`, whose result is always `bit varying`: the
582                    // operands widen to varbit and the concatenated length
583                    // isn't a fixed `bit(N)`. So `B'10' || B'11'` is
584                    // `bit varying`, matching PG's pg_typeof — not the left
585                    // operand's `bit`.
586                    let ty = if matches!(op, B::Concat)
587                        && matches!(inner.ty, DataType::Bit(_) | DataType::BitVarying(_))
588                    {
589                        // The concatenation's length is not a fixed
590                        // typmod, so it widens to unbounded varbit.
591                        DataType::BitVarying(0)
592                    } else {
593                        inner.ty
594                    };
595                    Some(ExprShape {
596                        name: "?column?".to_string(),
597                        ty,
598                        nullable: true,
599                    })
600                }
601            }
602        }
603        // (array_agg(…))[1] — element type of the array.
604        Expr::ArraySubscript { target, .. } => {
605            let inner = describe_expr(target, schema_cols)?;
606            let elem = match inner.ty {
607                DataType::IntArray => DataType::Int,
608                DataType::BigIntArray => DataType::BigInt,
609                DataType::TextArray => DataType::Text,
610                other => other,
611            };
612            Some(ExprShape {
613                name: "?column?".to_string(),
614                ty: elem,
615                nullable: true,
616            })
617        }
618        // arr[lo:hi] — slice keeps the array type.
619        Expr::ArraySlice { target, .. } => describe_expr(target, schema_cols),
620        // v7.37.43-T4 — `$N` placeholders in a projection. Pre-T4 this
621        // arm fell through to `_ => None`, which made
622        // `describe_select_items` return an empty Vec, which made
623        // pgwire send `NoData` in response to Describe. But the same
624        // placeholder DID produce a column at Execute time (engine
625        // substitutes the bound value, the column appears in the
626        // result), so pgwire then sent `RowDescription + DataRow`
627        // anyway. The wire stream went `NoData` → `RowDescription` →
628        // `DataRow` — a sequence libpq / sqlx-postgres / pg-jdbc don't
629        // accept (NoData is a terminal answer: no rows ever). sqlx
630        // hit `unexpected message: RowDescription` mid-stream, which
631        // dispatched into its boxed-future error recovery and
632        // stack-overflowed the calling thread.
633        //
634        // The repro is `SELECT $1` (literally any sqlx prepared SELECT
635        // with a parameter) — including the `pg_advisory_lock($1)`
636        // that `sqlx::migrate!()` issues right after `current_database()`.
637        // Pre-T4 every sqlx user crashed on the very first parameterised
638        // prepared SELECT.
639        //
640        // Fix: when describing a `$N` placeholder, return a Text shape
641        // (oid 25 — the SQL text format that pgwire returns on the
642        // text wire path) so Describe yields a RowDescription with one
643        // column. The actual data type comes from the bound value at
644        // Execute time; sqlx tolerates this because the text wire
645        // format makes the per-column type advisory rather than load-
646        // bearing. The column name "?column?" mirrors PG's own
647        // canonical projection-of-an-expression name.
648        Expr::Placeholder(_) => Some(ExprShape {
649            name: "?column?".to_string(),
650            ty: DataType::Text,
651            nullable: true,
652        }),
653        _ => None,
654    }
655}
656
657/// Static return-type map for the SQL function library. Returns
658/// None for functions whose return type genuinely depends on
659/// runtime values in a way the planner can't statically resolve
660/// (e.g. `coalesce(arg1, arg2)` where arg1 is NULL literal — the
661/// caller's type-inference cascade handles those).
662fn function_return_shape(
663    name: &str,
664    args: &[Expr],
665    schema_cols: &[ColumnSchema],
666) -> Option<ExprShape> {
667    let lc = name.to_ascii_lowercase();
668    let (ty, nullable) = match lc.as_str() {
669        // Time-of-now → engine clock literals.
670        "now"
671        | "current_timestamp"
672        | "localtimestamp"
673        | "transaction_timestamp"
674        | "statement_timestamp"
675        | "clock_timestamp" => (DataType::Timestamptz, false),
676        "current_date" => (DataType::Date, false),
677        // v7.39 (tz epic) — AT TIME ZONE flips the flavour: a naive
678        // timestamp AT ZONE is a timestamptz, a timestamptz AT ZONE a
679        // naive timestamp (PG).
680        "timezone" if args.len() == 2 => {
681            let src_is_tstz = args
682                .get(1)
683                .and_then(|a| describe_expr(a, schema_cols))
684                .is_some_and(|s| matches!(s.ty, DataType::Timestamptz));
685            (
686                if src_is_tstz {
687                    DataType::Timestamp
688                } else {
689                    DataType::Timestamptz
690                },
691                true,
692            )
693        }
694        // v7.39 (round 755, F31-B6) — true TIME family, PG18-measured
695        // (`time with time zone` / `time without time zone`).
696        "current_time" => (DataType::TimeTz, false),
697        "localtime" => (DataType::Time, false),
698        // Text-returning library — every fn that produces a string.
699        "concat"
700        | "concat_ws"
701        | "format"
702        | "lower"
703        | "upper"
704        | "trim"
705        | "ltrim"
706        | "rtrim"
707        | "substring"
708        | "substr"
709        | "replace"
710        | "split_part"
711        | "repeat"
712        | "lpad"
713        | "rpad"
714        | "left"
715        | "right"
716        | "translate"
717        | "regexp_replace"
718        | "to_char"
719        | "encode"
720        | "host"
721        | "network"
722        | "version"
723        | "database"
724        | "current_database"
725        | "current_schema"
726        | "current_user"
727        | "session_user"
728        | "user"
729        | "pg_get_serial_sequence"
730        | "pg_get_constraintdef"
731        | "pg_get_indexdef"
732        | "date_format"
733        | "pg_typeof" => (DataType::Text, true),
734        // Bytes-returning.
735        "decode" | "hex" => (DataType::Bytes, true),
736        // Integer-returning length / position helpers.
737        "length" | "char_length" | "character_length" | "octet_length" | "bit_length"
738        | "position" | "strpos" | "ascii" | "masklen" => (DataType::Int, true),
739        // BigInt-returning.
740        "count" | "count_star" | "nextval" | "currval" | "lastval" | "unix_timestamp" => {
741            (DataType::BigInt, true)
742        }
743        // Float / double-precision returns.
744        "random" | "ts_rank" | "ts_rank_cd" | "similarity" | "ln" | "log" | "log2" | "exp"
745        | "sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "atan2" | "degrees" | "radians"
746        | "pi" => (DataType::Float, true),
747        // Boolean predicate-returning.
748        "starts_with" => (DataType::Bool, true),
749        // Arrays.
750        "regexp_matches"
751        | "regexp_split_to_array"
752        | "show_trgm"
753        | "string_to_array"
754        | "array_remove"
755        | "array_append"
756        | "array_cat" => (DataType::TextArray, true),
757        // JSON.
758        "to_json"
759        | "to_jsonb"
760        | "json_build_object"
761        | "jsonb_build_object"
762        | "json_build_array"
763        | "jsonb_build_array"
764        | "json_object"
765        | "jsonb_object"
766        | "jsonb_set"
767        | "jsonb_insert"
768        | "jsonb_path_query"
769        | "jsonb_path_query_first"
770        | "jsonb_path_query_array"
771        | "json_path_query" => (DataType::Json, true),
772        // FTS types.
773        "to_tsvector" => (DataType::TsVector, true),
774        "to_tsquery" | "plainto_tsquery" | "phraseto_tsquery" | "websearch_to_tsquery" => {
775            (DataType::TsQuery, true)
776        }
777        // v7.17.0 — UUID generators. `gen_random_uuid()` is the
778        // PG built-in; `uuid_generate_v4()` is the historical
779        // uuid-ossp alias. Both return a NOT NULL UUID — non-
780        // nullable since neither takes args and neither can fail.
781        "gen_random_uuid" | "uuid_generate_v4" => (DataType::Uuid, false),
782        // Interval.
783        "age" => (DataType::Interval, true),
784        // Timestamp-returning. `from_unixtime` switches to TEXT
785        // when called with a format-string second arg — handled
786        // below via arity check.
787        "make_timestamp" => (DataType::Timestamp, true),
788        // v7.39 (read01 round 77) — date_trunc / date_bin return the type of
789        // the timestamp they were HANDED (PG has a timestamptz overload of
790        // each), so a truncated timestamptz keeps its `+00` on the way out.
791        // Pinning them to Timestamp silently dropped the offset.
792        // v7.39 (read01 round 114) — a `date` argument resolves to PG's
793        // *timestamptz* overload (timestamptz is date's preferred implicit
794        // cast), so `date_trunc('q', date '…')` is timestamptz (`…+00`), not a
795        // plain timestamp. Only a bare `timestamp` stays timestamp.
796        "date_trunc" | "date_bin" => {
797            let src = args.get(1)?;
798            let ty = describe_expr(src, schema_cols).map_or(DataType::Timestamp, |s| match s.ty {
799                DataType::Timestamptz | DataType::Date => DataType::Timestamptz,
800                _ => DataType::Timestamp,
801            });
802            (ty, true)
803        }
804        // v7.39 (round 522) — PG's `date_add` / `date_subtract` are
805        // declared over timestamptz and answer timestamptz; the parser
806        // writes the coercion PG performs, so the first argument carries
807        // the answer. MySQL's DATE_ADD gets no such cast and keeps its
808        // own DATE / DATETIME result.
809        "date_add" | "date_subtract" => {
810            // Only the PG-dialect form is typed here — the one whose
811            // first argument the parser lifted to timestamptz. MySQL's
812            // DATE_ADD gets no such cast and keeps the typing it had.
813            let src = args.first()?;
814            if !matches!(
815                describe_expr(src, schema_cols).map(|s| s.ty),
816                Some(DataType::Timestamptz)
817            ) {
818                return None;
819            }
820            (DataType::Timestamptz, true)
821        }
822        "from_unixtime" => {
823            if args.len() >= 2 {
824                (DataType::Text, true)
825            } else {
826                (DataType::Timestamp, true)
827            }
828        }
829        "make_date" | "to_date" => (DataType::Date, true),
830        // v7.39 (read01 formatting.c) — PG's to_timestamp (both the epoch
831        // and the format form) returns timestamptz.
832        "to_timestamp" => (DataType::Timestamptz, true),
833        // v7.39 (read01 timestamp.c) — make_timestamptz returns tstz.
834        "make_timestamptz" => (DataType::Timestamptz, true),
835        // v7.39 (read01 uuid.c) — uuid_extract_timestamp returns tstz.
836        "uuid_extract_timestamp" => (DataType::Timestamptz, true),
837        "date_part" | "extract" => (DataType::Float, true),
838        // v7.26 (round-20 C) — remaining aggregate signatures
839        // (count / ts_rank were already mapped above). PG types
840        // these from the aggregate's declaration; SPG used to
841        // default them to TEXT, breaking sqlx typed decodes.
842        "bool_and" | "bool_or" | "every" => (DataType::Bool, true),
843        "string_agg" => (DataType::Text, true),
844        "array_agg" => {
845            let elem = args
846                .first()
847                .and_then(|a| describe_expr(a, schema_cols))
848                .map(|s| s.ty);
849            let ty = match elem {
850                Some(DataType::Int | DataType::SmallInt) => DataType::IntArray,
851                Some(DataType::BigInt) => DataType::BigIntArray,
852                _ => DataType::TextArray,
853            };
854            (ty, true)
855        }
856        // v7.39 (read01 round 77) — the conditional family resolves a COMMON
857        // type across its arguments, and an untyped NULL literal contributes
858        // nothing to it. Taking `args[0]` unconditionally meant
859        // `coalesce(NULL, <timestamptz>)` described as "no shape at all", so
860        // the timestamptz lost its `+00` — the type was decided by argument
861        // POSITION rather than by the arguments.
862        "coalesce" | "greatest" | "least" | "ifnull" | "isnull" | "nullif" => {
863            let shapes: Vec<ExprShape> = args
864                .iter()
865                .filter(|a| !matches!(a, Expr::Literal(Literal::Null)))
866                .filter_map(|a| describe_expr(a, schema_cols))
867                .collect();
868            let first = shapes.first()?;
869            let types: Vec<DataType> = shapes.iter().map(|s| s.ty).collect();
870            return Some(ExprShape {
871                name: "?column?".to_string(),
872                ty: common_type(&types).unwrap_or(first.ty),
873                nullable: true,
874            });
875        }
876        // v7.39 (round 268) — sum / avg PROMOTE; they were lumped in
877        // with the pass-through math below and reported the argument's
878        // own type. The runtime has always promoted correctly
879        // (sum(int) really does return bigint), so this was a static
880        // description that disagreed with the value the engine sends —
881        // a driver that trusts the RowDescription decodes an int4 and
882        // gets eight bytes. All types measured on PG 18.4.
883        "sum" => {
884            let inner = describe_expr(args.first()?, schema_cols)?;
885            let ty = match inner.ty {
886                DataType::SmallInt | DataType::Int => DataType::BigInt,
887                DataType::BigInt => DataType::Numeric {
888                    precision: 0,
889                    scale: 0,
890                },
891                other => other,
892            };
893            return Some(ExprShape {
894                name: "?column?".to_string(),
895                ty,
896                nullable: true,
897            });
898        }
899        "avg" => {
900            let inner = describe_expr(args.first()?, schema_cols)?;
901            let ty = match inner.ty {
902                DataType::SmallInt | DataType::Int | DataType::BigInt => DataType::Numeric {
903                    precision: 0,
904                    scale: 0,
905                },
906                // real averages as double precision, unlike sum, which
907                // stays real.
908                DataType::Real => DataType::Float,
909                other => other,
910            };
911            return Some(ExprShape {
912                name: "?column?".to_string(),
913                ty,
914                nullable: true,
915            });
916        }
917        // Pass-through math: derive the type from the first arg.
918        "max" | "min" | "abs" | "floor" | "ceil" | "ceiling" | "round" | "trunc" | "mod"
919        | "power" | "pow" | "sqrt" | "sign" => {
920            // Use the first arg's shape; fall back to Float for math
921            // that can promote (e.g. mod(2, 3) → Float? No — keep
922            // Int. The caller's coerce_value handles promotion at
923            // INSERT time.)
924            let first = args.first()?;
925            let inner = describe_expr(first, schema_cols)?;
926            return Some(ExprShape {
927                name: "?column?".to_string(),
928                ty: inner.ty,
929                nullable: true, // arithmetic / coalesce can produce NULL on bad input
930            });
931        }
932        _ => return None,
933    };
934    Some(ExprShape {
935        name: "?column?".to_string(),
936        ty,
937        nullable,
938    })
939}
940
941fn collect_parameter_oids(stmt: &Statement, catalog: &Catalog) -> Vec<u32> {
942    let max = max_placeholder(stmt);
943    if max == 0 {
944        return Vec::new();
945    }
946    // PG ParameterDescription is one OID per declared $N.
947    //
948    // v7.37.43-T4 — return TEXT (oid 25) instead of "unknown"
949    // (oid 0) for placeholders SPG can't statically type. sqlx-
950    // postgres 0.8 treats OID 0 as "user-defined type, fetch
951    // metadata from pg_catalog.pg_type", which routes through
952    // `maybe_fetch_type_info_by_oid` → `fetch_type_by_oid`'s
953    // `SELECT … FROM pg_catalog.pg_type WHERE oid = $1`. That
954    // inner query also has a placeholder typed OID 0, recursing
955    // through ParameterDescription handling until the calling
956    // thread's stack overflows. Affected `sqlx::migrate!()`
957    // (every drop-in user) and `sqlx::query("…").bind(…)`
958    // (every parameterised SELECT) on the very first Execute.
959    //
960    // OID 25 is the TEXT built-in. sqlx's `PgTypeInfo::try_from_oid(25)`
961    // returns `Some(Text)` synchronously and skips the catalog
962    // round-trip. The actual data type comes from the bound
963    // value at Execute time; the text wire format makes the
964    // type advisory rather than load-bearing.
965    //
966    // v7.39 (binary results) — but binary-first clients
967    // (tokio-postgres, JDBC binary mode) VALIDATE the declared OID
968    // against the Rust/Java value they bind, so TEXT-for-everything
969    // rejects `WHERE i = $1` with an i32. Infer the real type where
970    // the context makes it unambiguous — `col <op> $N` picks the
971    // column's type, `INSERT INTO t (…) VALUES ($1, …)` / `UPDATE t
972    // SET col = $N` pick the target column — and keep TEXT for
973    // anything the walk can't pin (sqx's oid-0 recursion stays
974    // fixed because 25 remains the fallback, never 0).
975    let mut oids = alloc::vec![25u32; max as usize];
976    infer_placeholder_oids(stmt, catalog, &mut oids);
977    oids
978}
979
980/// v7.39 — PG type OID for a column DataType, for ParameterDescription.
981/// Only the families drivers actually bind; anything else keeps the
982/// TEXT fallback upstream.
983fn wire_oid_for(ty: DataType) -> u32 {
984    match ty {
985        DataType::Bool => 16,
986        DataType::SmallInt => 21,
987        DataType::Int => 23,
988        DataType::BigInt => 20,
989        DataType::Real => 700,
990        DataType::Float => 701,
991        DataType::Numeric { .. } => 1700,
992        DataType::Date => 1082,
993        DataType::Time => 1083,
994        DataType::Timestamp => 1114,
995        DataType::Timestamptz => 1184,
996        DataType::Uuid => 2950,
997        DataType::Bytes => 17,
998        DataType::Json => 114,
999        DataType::Jsonb => 3802,
1000        _ => 25,
1001    }
1002}
1003
1004/// v7.39 — best-effort placeholder typing from column context.
1005fn infer_placeholder_oids(stmt: &Statement, catalog: &Catalog, oids: &mut [u32]) {
1006    let col_oid = |schema: &[ColumnSchema], name: &spg_sql::ast::ColumnName| -> Option<u32> {
1007        schema
1008            .iter()
1009            .find(|c| c.name.eq_ignore_ascii_case(&name.name))
1010            .map(|c| wire_oid_for(c.ty))
1011    };
1012    let mut mark = |n: u16, oid: Option<u32>| {
1013        if let Some(oid) = oid
1014            && let Some(slot) = oids.get_mut((n as usize).saturating_sub(1))
1015        {
1016            *slot = oid;
1017        }
1018    };
1019    match stmt {
1020        Statement::Select(s) => {
1021            let Some(from) = &s.from else { return };
1022            if !from.joins.is_empty() {
1023                return;
1024            }
1025            let Some(t) = catalog.get(&from.primary.name) else {
1026                return;
1027            };
1028            let schema = t.schema().columns.clone();
1029            if let Some(w) = &s.where_ {
1030                walk_expr(w, &mut |e| {
1031                    if let Expr::Binary { lhs, rhs, .. } = e {
1032                        match (lhs.as_ref(), rhs.as_ref()) {
1033                            (Expr::Column(c), Expr::Placeholder(n))
1034                            | (Expr::Placeholder(n), Expr::Column(c)) => {
1035                                mark(*n, col_oid(&schema, c));
1036                            }
1037                            _ => {}
1038                        }
1039                    }
1040                });
1041            }
1042        }
1043        Statement::Insert(ins) => {
1044            let Some(t) = catalog.get(&ins.table) else {
1045                return;
1046            };
1047            let schema = t.schema().columns.clone();
1048            // Column order: the explicit column list, else table order.
1049            let order: alloc::vec::Vec<usize> = match &ins.columns {
1050                Some(cols) if !cols.is_empty() => cols
1051                    .iter()
1052                    .map(|name| {
1053                        schema
1054                            .iter()
1055                            .position(|c| c.name.eq_ignore_ascii_case(name))
1056                            .unwrap_or(usize::MAX)
1057                    })
1058                    .collect(),
1059                _ => (0..schema.len()).collect(),
1060            };
1061            for row in &ins.rows {
1062                for (i, e) in row.iter().enumerate() {
1063                    if let Expr::Placeholder(n) = e
1064                        && let Some(&pos) = order.get(i)
1065                        && let Some(c) = schema.get(pos)
1066                    {
1067                        mark(*n, Some(wire_oid_for(c.ty)));
1068                    }
1069                }
1070            }
1071        }
1072        Statement::Update(u) => {
1073            let Some(t) = catalog.get(&u.table) else {
1074                return;
1075            };
1076            let schema = t.schema().columns.clone();
1077            for (col, e) in &u.assignments {
1078                if let Expr::Placeholder(n) = e
1079                    && let Some(c) = schema.iter().find(|c| c.name.eq_ignore_ascii_case(col))
1080                {
1081                    mark(*n, Some(wire_oid_for(c.ty)));
1082                }
1083            }
1084            if let Some(w) = &u.where_ {
1085                walk_expr(w, &mut |e| {
1086                    if let Expr::Binary { lhs, rhs, .. } = e {
1087                        match (lhs.as_ref(), rhs.as_ref()) {
1088                            (Expr::Column(c), Expr::Placeholder(n))
1089                            | (Expr::Placeholder(n), Expr::Column(c)) => {
1090                                mark(*n, col_oid(&schema, c));
1091                            }
1092                            _ => {}
1093                        }
1094                    }
1095                });
1096            }
1097        }
1098        _ => {}
1099    }
1100}
1101
1102fn max_placeholder(stmt: &Statement) -> u16 {
1103    let mut max: u16 = 0;
1104    walk_statement(stmt, &mut |e| {
1105        if let Expr::Placeholder(n) = e {
1106            max = max.max(*n);
1107        }
1108    });
1109    max
1110}
1111
1112fn walk_statement(stmt: &Statement, f: &mut impl FnMut(&Expr)) {
1113    match stmt {
1114        Statement::Select(s) => walk_select(s, f),
1115        Statement::Insert(s) => {
1116            for row in &s.rows {
1117                for e in row {
1118                    walk_expr(e, f);
1119                }
1120            }
1121        }
1122        Statement::Update(s) => {
1123            for (_, e) in &s.assignments {
1124                walk_expr(e, f);
1125            }
1126            if let Some(w) = &s.where_ {
1127                walk_expr(w, f);
1128            }
1129        }
1130        Statement::Delete(s) => {
1131            if let Some(w) = &s.where_ {
1132                walk_expr(w, f);
1133            }
1134        }
1135        // v7.39 (round 225) — the body is a whole Statement (SELECT or DML).
1136        Statement::Explain(inner) => {
1137            if let Statement::Select(sel) = &*inner.inner {
1138                walk_select(sel, f);
1139            }
1140        }
1141        _ => {}
1142    }
1143}
1144
1145fn walk_select(s: &SelectStatement, f: &mut impl FnMut(&Expr)) {
1146    for item in &s.items {
1147        if let SelectItem::Expr { expr, .. } = item {
1148            walk_expr(expr, f);
1149        }
1150    }
1151    if let Some(w) = &s.where_ {
1152        walk_expr(w, f);
1153    }
1154    if let Some(h) = &s.having {
1155        walk_expr(h, f);
1156    }
1157    if let Some(gb) = &s.group_by {
1158        for e in gb {
1159            walk_expr(e, f);
1160        }
1161    }
1162    for (_, peer) in &s.unions {
1163        walk_select(peer, f);
1164    }
1165}
1166
1167fn walk_expr(e: &Expr, f: &mut impl FnMut(&Expr)) {
1168    f(e);
1169    match e {
1170        Expr::NamedArg { expr, .. } => walk_expr(expr, f),
1171        Expr::Variadic(expr) => walk_expr(expr, f),
1172        Expr::AggregateOrdered { call, order_by, .. } => {
1173            walk_expr(call, f);
1174            for o in order_by {
1175                walk_expr(&o.expr, f);
1176            }
1177        }
1178        Expr::Binary { lhs, rhs, .. } => {
1179            walk_expr(lhs, f);
1180            walk_expr(rhs, f);
1181        }
1182        Expr::Unary { expr, .. } => walk_expr(expr, f),
1183        Expr::Cast { expr, .. } | Expr::FieldAccess { base: expr, .. } => walk_expr(expr, f),
1184        Expr::IsNull { expr, .. } | Expr::BoolTest { expr, .. } => walk_expr(expr, f),
1185        Expr::Like { expr, pattern, .. } => {
1186            walk_expr(expr, f);
1187            walk_expr(pattern, f);
1188        }
1189        Expr::FunctionCall { args, .. } => {
1190            for a in args {
1191                walk_expr(a, f);
1192            }
1193        }
1194        Expr::WindowFunction {
1195            args,
1196            partition_by,
1197            order_by,
1198            ..
1199        } => {
1200            for a in args {
1201                walk_expr(a, f);
1202            }
1203            for p in partition_by {
1204                walk_expr(p, f);
1205            }
1206            for (o, _, _) in order_by {
1207                walk_expr(o, f);
1208            }
1209        }
1210        Expr::ScalarSubquery(s) => walk_select(s, f),
1211        Expr::Exists { subquery, .. } => walk_select(subquery, f),
1212        Expr::InSubquery { expr, subquery, .. } => {
1213            walk_expr(expr, f);
1214            walk_select(subquery, f);
1215        }
1216        Expr::RowInSubquery { row, subquery, .. } => {
1217            for el in row {
1218                walk_expr(el, f);
1219            }
1220            walk_select(subquery, f);
1221        }
1222        Expr::RowCmpSubquery { row, subquery, .. } => {
1223            for el in row {
1224                walk_expr(el, f);
1225            }
1226            walk_select(subquery, f);
1227        }
1228        Expr::Extract { source, .. } => walk_expr(source, f),
1229        Expr::Array(items) => {
1230            for elem in items {
1231                walk_expr(elem, f);
1232            }
1233        }
1234        Expr::ArraySubscript { target, index } => {
1235            walk_expr(target, f);
1236            walk_expr(index, f);
1237        }
1238        Expr::ArraySlice { target, lo, hi } => {
1239            walk_expr(target, f);
1240            if let Some(l) = lo {
1241                walk_expr(l, f);
1242            }
1243            if let Some(h) = hi {
1244                walk_expr(h, f);
1245            }
1246        }
1247        Expr::AnyAll { expr, array, .. } => {
1248            walk_expr(expr, f);
1249            walk_expr(array, f);
1250        }
1251        Expr::InList { expr, list, .. } => {
1252            walk_expr(expr, f);
1253            for item in list {
1254                walk_expr(item, f);
1255            }
1256        }
1257        Expr::Case {
1258            operand,
1259            branches,
1260            else_branch,
1261        } => {
1262            if let Some(o) = operand {
1263                walk_expr(o, f);
1264            }
1265            for (w, t) in branches {
1266                walk_expr(w, f);
1267                walk_expr(t, f);
1268            }
1269            if let Some(e) = else_branch {
1270                walk_expr(e, f);
1271            }
1272        }
1273        Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => {}
1274    }
1275}
1276
1277/// v7.39 (round 310, V31) — a TIMESTAMPTZ array has to be recognised from
1278/// the element EXPRESSIONS, not from their values.
1279///
1280/// `Value::Timestamp` is the runtime form of both timestamp types — the
1281/// zone-ness rides on the static type, exactly as enum-ness and
1282/// composite-ness do (rounds 54 / 56). An array builder picks its variant
1283/// by looking at what it materialised, so it could only ever answer
1284/// `timestamp without time zone[]`; the array then would not go into a
1285/// `timestamptz[]` column and rendered without its offset.
1286///
1287/// Shared because there are two array builders — the evaluator's and the
1288/// literal-folding one INSERT VALUES uses. Fixing only the first left the
1289/// INSERT still rejecting its own well-typed array.
1290///
1291/// Only a uniformly-timestamptz constructor is upgraded. A mixed one has
1292/// already been unified by the caller, and if that settled on a plain
1293/// timestamp then plain is what PG resolves it to.
1294pub(crate) fn upgrade_timestamptz_array(
1295    v: Value<'static>,
1296    items: &[Expr],
1297    columns: &[ColumnSchema],
1298) -> Value<'static> {
1299    let Value::TimestampArray(elems) = v else {
1300        return v;
1301    };
1302    if items.is_empty()
1303        || !items
1304            .iter()
1305            .all(|e| describe_expr(e, columns).is_some_and(|s| s.ty == DataType::Timestamptz))
1306    {
1307        return Value::TimestampArray(elems);
1308    }
1309    Value::TimestamptzArray(elems)
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315    use crate::Engine;
1316    use spg_sql::parser::parse_statement;
1317
1318    fn parse(sql: &str) -> Statement {
1319        parse_statement(sql).expect("parses")
1320    }
1321
1322    #[test]
1323    fn describe_returns_columns_for_wildcard_select() {
1324        let mut eng = Engine::new();
1325        eng.execute("CREATE TABLE t (a INT, b TEXT)").unwrap();
1326        let stmt = eng.prepare("SELECT * FROM t").unwrap();
1327        let (params, cols) = describe_prepared(&stmt, eng_catalog(&eng));
1328        assert_eq!(params, Vec::<u32>::new());
1329        assert_eq!(cols.len(), 2);
1330        assert_eq!(cols[0].name, "a");
1331        assert_eq!(cols[0].ty, DataType::Int);
1332        assert_eq!(cols[1].name, "b");
1333        assert_eq!(cols[1].ty, DataType::Text);
1334    }
1335
1336    #[test]
1337    fn describe_returns_columns_for_projection_select() {
1338        let mut eng = Engine::new();
1339        eng.execute("CREATE TABLE t (a INT, b TEXT)").unwrap();
1340        let stmt = eng.prepare("SELECT b, a FROM t").unwrap();
1341        let (_, cols) = describe_prepared(&stmt, eng_catalog(&eng));
1342        assert_eq!(cols.len(), 2);
1343        assert_eq!(cols[0].name, "b");
1344        assert_eq!(cols[0].ty, DataType::Text);
1345        assert_eq!(cols[1].name, "a");
1346        assert_eq!(cols[1].ty, DataType::Int);
1347    }
1348
1349    #[test]
1350    fn describe_counts_placeholders() {
1351        let stmt = parse("SELECT * FROM t WHERE id = $1 AND name = $2");
1352        let (params, _) = describe_prepared(&stmt, &Catalog::new());
1353        // v7.37.43-T4 — placeholders report OID 25 (TEXT) instead of
1354        // OID 0 ("unknown") because sqlx-postgres 0.8 routes OID 0
1355        // through a recursive pg_catalog.pg_type fetch that
1356        // stack-overflows the caller; 25 hits the synchronous
1357        // PgTypeInfo::try_from_oid fast path. See `collect_parameter_oids`
1358        // for the full rationale.
1359        assert_eq!(params, alloc::vec![25u32, 25u32]);
1360    }
1361
1362    #[test]
1363    fn describe_resolves_a_join_namespace() {
1364        // v7.39 (round 462) — this used to assert the opposite: a JOIN
1365        // fell through to NoData "which drivers tolerate". They do not.
1366        // Execute owes no RowDescription, so NoData here left every
1367        // extended-protocol client holding rows with no column metadata.
1368        // PG18 describes the same statement as four columns.
1369        let mut eng = Engine::new();
1370        eng.execute("CREATE TABLE a (id INT)").unwrap();
1371        eng.execute("CREATE TABLE b (id INT)").unwrap();
1372        let stmt = eng
1373            .prepare("SELECT * FROM a JOIN b ON a.id = b.id")
1374            .unwrap();
1375        let (_, cols) = describe_prepared(&stmt, eng_catalog(&eng));
1376        let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
1377        // PG labels a join's `*` by the BARE column names, duplicates and
1378        // all — measured on PG18: `id | id`.
1379        assert_eq!(names, alloc::vec!["id", "id"]);
1380    }
1381
1382    #[test]
1383    fn describe_emits_empty_columns_for_non_select() {
1384        let stmt = parse("INSERT INTO t VALUES (1)");
1385        let (params, cols) = describe_prepared(&stmt, &Catalog::new());
1386        assert_eq!(params, Vec::<u32>::new());
1387        assert!(cols.is_empty());
1388    }
1389
1390    fn eng_catalog(eng: &Engine) -> &Catalog {
1391        eng.catalog()
1392    }
1393}