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}