Repository navigation
sql: add TRY_CAST to handle errors in (most) casts - #38845
Conversation
a1477a7 to
778cd9f
Compare
QA LLM Review1. MEDIUM --
|
b09d293 to
160393c
Compare
antiguru
left a comment
There was a problem hiding this comment.
Looks good overall, just some questions:
- Can we eliminate casts that cannot fail, or downgrade them to regular casts in
MSE::reduce? - Could we make it an invariant that the inner expression of a
try_castis alwayscould_error? Otherwise, there's not much point in try casting.
We already turn
We could make it an invariant---we already generate things this way, but I'm not 100% certain that nobody messes with things on the way to LIR. Worth adding assertions? |
### Motivation Fixes https://linear.app/materializeinc/issue/DB-201/casts-from-char-crash-the-optimizer-or-compute. ### Description Work on #38845 uncovered an existing bug with casts from `"char"` a/k/a `PgLegacyChar`, which could lead to optimizer panics (during constant folding) or replica crashes (when running an operator over a `"char"` column). The core issue is that `"char"` is represented as `Datum::UInt8`, but `plan_cast` treats every string-category source as if it were `text`, so it applied the `text`-source cast functions directly to a byte datum. The fix is scoped to the from-`"char"` direction of `plan_cast`. When the source is `"char"`, use its registered cast if one exists (only `int4`, by byte value, as in PostgreSQL), and otherwise render the byte to `text` before applying the `text`-source cast. Nothing else in `plan_cast` changes. The only user-visible change is that casts from `"char"` to numbers and other non-string types now work instead of failing: `"char"` to `int4` yields the byte value, and every other target goes through the text representation (so `'7'::"char"::int4` is 55 while `'7'::"char"::int8` is 7). Casts *to* `"char"` are unchanged. Nothing that plans on `main` changes behavior, so no catalog migration is needed. A differential sweep over all pairs of built-in scalar types in explicit and assignment context, comparing a `main` build against this branch, differs on exactly the `"char"`-to-non-string explicit casts, all of which were panics or internal errors on `main`. Note that PostgreSQL 15 and later reject `"char"` to non-string casts other than `int4` outright, because `"char"` moved from the string type category to the internal category. PostgreSQL 14 and earlier accept them with the same results as this change. ### Tips for reviewer The `"char"` to `int4` cast function already existed (`CastPgLegacyCharToInt32`) but was unreachable, since the string-category routing ran first. It is now used for that one pair. ### Verification Existing SLTs pass; added tests to `char.slt` covering `"char"` casts to `int2vector`, `int8`, `int4`, and `bool`, an invalid-input error, and column sources over a table. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ggevay
left a comment
There was a problem hiding this comment.
LGTM, just minor comments.
(While rebasing, please also try dropping the "char" skip in the cast proptest in catalog.rs, and with it SqlScalarType::contains (its only caller), and update the DB-201 note in the try_cast.slt header, since those casts no longer panic.)
| .map(|(_name, ty)| &ty.scalar_type) | ||
| .cloned() | ||
| .collect(), | ||
| // Under `NullFallback` the target field types are not pushed into |
There was a problem hiding this comment.
The Parameter arm below keeps CAST's typing under NullFallback: an untyped parameter takes the target type, so a bad bound value fails at bind instead of becoming NULL. With psycopg, SELECT TRY_CAST(%s AS int) with 'abc' fails with unable to decode parameter: invalid input syntax for type integer. That seems right, since the parameter's type applies to the whole statement, but the docs could say so and suggest TRY_CAST($1::text AS int), which returns NULL. Through this arm, TRY_CAST(ROW($1, 'a') AS rec2) types $1 as text instead, so a parameter inside a ROW behaves differently from a bare one.
| let a = a.eval(datums, temp_storage)?; | ||
| match self.inner.eval(&[a], temp_storage, &FIRST_COLUMN) { | ||
| Ok(datum) => Ok(datum), | ||
| Err(_) => Ok(Datum::Null), |
There was a problem hiding this comment.
This also turns EvalError::Internal into NULL, which hides planner bugs: before #38899, TRY_CAST('7'::"char" AS int8) returned NULL where CAST reported internal error: invalid input type. Returning the error would break could_error() == false, but a soft_panic_no_log! on Internal before returning NULL would keep such bugs visible in CI without logging per row in production. No cast impl returns Internal for bad input, so it can't fire on user data.
| write!(f, "cannot create temporary item in non-temporary schema") | ||
| } | ||
| Self::TryCastUnsupported { from, to } => { | ||
| write!(f, "TRY_CAST does not support casting from {from} to {to}") |
There was a problem hiding this comment.
This reads exactly like InvalidCast with name = "TRY_CAST": TRY_CAST(1 AS int4range) (no such cast) and TRY_CAST('t' AS regclass) (the cast exists, but TRY_CAST can't wrap it) both print TRY_CAST does not support casting from .... Saying that the cast exists but only CAST supports it would tell users what to do.
|
|
||
| ### `TRY_CAST` | ||
|
|
||
| {{< public-preview />}} |
There was a problem hiding this comment.
The flag defaults off and its error says private preview, so per doc/developer/project-lifecycle.md this should be {{< private-preview />}} until the flag is on everywhere. Also, version-added: v26.43 in sql_funcs.yml is stale: this isn't in v26.43, v26.44 or v26.45. (Sorry for the late review!)
There was a problem hiding this comment.
I went ahead and turned this on by default, as the flag is an escape hatch. (Code for TRY_CAST should only be triggered by its syntax, anyway.)
| with `CAST` or `::` instead. | ||
|
|
||
| `TRY_CAST` supports every explicit cast that `CAST` does with some exceptions: | ||
| it does not currently casts between `MAP` types; it does not support casts to or |
There was a problem hiding this comment.
CAST has no map-to-map cast either, so this isn't a TRY_CAST exception (also "casts" -> "cast"). An actual exception that the list doesn't mention is the dropped type hint: CAST(ARRAY[s, 2] AS int4[]) plans, but TRY_CAST(ARRAY[s, 2] AS int4[]) fails with ARRAY types text and integer cannot be matched, so constructor elements must agree on a type on their own.
| x NULL NULL NULL | ||
|
|
||
| query TT rowsort | ||
| SELECT name, nullable::text FROM mz_columns WHERE id = (SELECT id FROM mz_materialized_views WHERE name = 'mv') AND name IN ('s', 'i') |
There was a problem hiding this comment.
Nit: nothing pins that an infallible TRY_CAST stays unwrapped and non-nullable. The structural test in catalog.rs uses UnaryFunc::try_cast as its own oracle, so an always-wrap mutant passes. The assertion you offered antiguru would also catch it if it runs before constant folding, since the matrix's infallible rows are constants. Otherwise, a column like TRY_CAST(s = '' AS int4) in this MV, expecting nullable = false, would.
- Document that a parameter typed by TRY_CAST is decoded as the target type at bind time, and how to get NULL instead. - Soft-panic when TryCast would swallow an EvalError::Internal, so a planner bug stays visible in CI while production still yields NULL. - Reword the refused-cast error to say the cast exists and CAST supports it. - Enable enable_try_cast by default and mark TRY_CAST as added in v26.45. - Rewrite the docs' list of differences from CAST: catalog-lookup casts, constructors typed on their own, and the MAP constructor rejection as a consequence of that. - Pin that an infallible TRY_CAST stays unwrapped and NOT NULL, and stop using UnaryFunc::try_cast as the coverage test's own oracle. - After rebasing onto the "char" cast fix, thread the failure mode through its cast lookup, regenerate the "char" matrix rows, and lift the property test's "char" exclusion. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
antiguru
left a comment
There was a problem hiding this comment.
Posted by Claude Code on behalf of @antiguru. This review is based on reading the diff. I did not build or run anything.
On TryCast wrapping another function
TryCast { inner: Box<UnaryFunc<E>> } wraps a function, not a scalar expression, so it never captures an argument expression. It holds expressions only through the payloads of casts like CastArrayToArray, CastList1ToList2, and CastRecord1ToRecord2, which already exist and already go through UnaryFunc<E>. What the PR adds is a recursive type (UnaryFunc contains TryCast contains UnaryFunc) plus the FirstColumn shim, which works around the &impl Eval signature of LazyUnaryFunc::eval. The alternatives look worse:
- An error-swallowing
try(expr)at the expression level would also catch errors raised by the argument, and this PR deliberately lets those propagate. - An error-mode flag on every fallible cast would multiply the variants and the registry entries.
- A
TryCast { cast_expr: E }evaluated against column 0, likeCastList1ToList2, would remove the HIR stage extraction. It would also nest more expressions inside functions, and properties such aspropagates_nullsand the output type would have to be derived fromEinstead of read off the wrapped function.
Wrapping a function is the narrowest of these options, so I think it is justified. The cost is that the optimizer can no longer see through the cast: inverse goes away, the wrapper is never monotone or eliminable, and rules that match on specific cast variants won't fire. That costs optimizations but not correctness.
Blocking
enable_try_castdefaults totrue(src/sql/src/session/vars/definitions.rs). Our convention is that a new flag defaults off in production and on in tests, andmzcompose/__init__.pyalready turns it on for tests. Once users create views that useTRY_CAST, the expressions are stored in the catalog and in LIR, so turning the feature off later is no longer possible. As written, the flag gates nothing.- Empty collection literals no longer plan (
plan_castinsrc/sql/src/plan/query.rs). UnderNullFallback,ARRAYandLISTliterals are planned without the type hint, soplan_arraybails with "cannot determine type of empty array". As a result,TRY_CAST(ARRAY[] AS int4[])andTRY_CAST(LIST[] AS int4 list)fail whereCASTsucceeds. An empty literal has no element cast that could fail, so it can keep the hint.MAPliterals are rejected outright, the emptyMAP[]included, and the docs should say so. No SLT covers either case.
Strong suggestions
- Make
TryCast.innerprivate. As apubfield, it lets anyone wrap an infallible function or anotherTryCast, and thetry_cast[infallible]registry sample exists only because the type allows that. KeepingUnaryFunc::try_castas the only constructor would enforce the invariant. Related:output_sql_typealways forces the output to be nullable, even when the inner function cannot error andintroduces_nullsis false. These two properties should agree. - Keep one
CastFailureMode. It is defined in bothsql-parserandmz_expr, but nothing inmz_expruses it. It is a planner concept, somz_sqlcould use the parser's enum (or own one intypeconv) and drop themz_exprcopy and its re-export. - Document the template requirement for stage extraction.
extract_unary_stages_for_try_castruns each template on aDatum::Dummysentinel and pattern-matches the result into a chain of unary calls. A template that produces any other shape makesTRY_CASTfail with "cannot fall back to NULL", even when the strict cast cannot error. The test incatalog.rsthat covers every type pair pins this down today, but the doc comment onCastTemplate::newshould state the requirement. - Add an
EXPLAINtest to the SLT. It would pin howtry_cast[...]renders and show the per-stage wrapping on the path that routes through text, for example text to int8 to int4. - Move the new adapter tests to their own file. They add about 370 lines to
src/adapter/src/catalog.rs, which is already 3,600 lines long. The PR also adds theproptestandmz-repr/proptestdev-dependencies to the adapter crate.
Nits
- The
TryCastUnsupportedmessage is "…; use CAST instead". The suggestion belongs in a hint, and the primary message should not use a semicolon to join two clauses. UnaryFunc::try_castonly wraps conditionally, which the name doesn't convey. A name liketry_cast_if_falliblewould.soft_panic_no_logturnsEvalError::Internalinto NULL in production without logging. That seems to be the intent, but it leaves no trace when it happens.
The snapshot and registry updates look correct. Adding a variant needs no LIR_VERSION bump.
|
Posted by Claude Code on behalf of @antiguru. This follows up on the An eager-only Every fn eval_datum<'a>(&'a self, a: Datum<'a>, temp_storage: &'a RowArena)
-> Result<Datum<'a>, EvalError>;With this signature, the derived |
| - signature: TRY_CAST (cast_expr) -> T? | ||
| description: Value as type `T`, or _NULL_ if the conversion fails. | ||
| url: /sql/functions/cast#try_cast | ||
| version-added: v26.45 |
There was a problem hiding this comment.
(v26.45 was cut on 10-02, so this is now v26.46 at the earliest.)
| /// Reports whether `predicate` holds for this type or for any type nested | ||
| /// within it: the element type of a list, array, map, or range, or a field | ||
| /// type of a record, at any depth. | ||
| pub fn contains(&self, predicate: &impl Fn(&SqlScalarType) -> bool) -> bool { |
There was a problem hiding this comment.
With the "char" skip gone from the proptest, this has no callers left except its own test, so both can go.
|
On the two blockers in antiguru's review:
Edit: Opened a PR for adjusting AGENTS.md's feature flag guidelines: #39718 |
### Motivation AGENTS.md told agents that every new feature flag defaults off in production and on in tests, and agent-written reviews now raise deviations as blockers (#38845). The right default depends on product context: big new syntax may need to stay off so users don't rely on it before the syntax is settled, while a small, low-risk flag can ship on as an escape hatch. An agent rarely has that context. ### Description * Agents ask a human for a new flag's production default instead of applying a fixed rule, and don't question the default in PR reviews. * In tests, a new flag should usually be on. The bullet now names where CI defaults are set: `get_variable_system_parameters` or `get_minimal_system_parameters` in `misc/python/materialize/mzcompose/__init__.py`, which `bin/sqllogictest` and mzcompose's `Materialized` service pass to environmentd. It used to point at `--system-parameter-default`, the flag those lists are delivered through. `TestHarness::with_system_parameter_default` stays for Rust integration tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Let's do this after your scalar func rewrite lands. |
These tweaks didn't make it into `TRY_CAST` #38845, which accidentally merged one step too early. - Keep the type hint for empty ARRAY, LIST, and MAP constructors under TRY_CAST, which have no element casts to protect. Only a non-empty MAP constructor is still rejected. - Make TryCast's inner function private, with UnaryFunc::try_cast_if_fallible as the only constructor, so a wrapper always holds a fallible function and is always nullable. - Use the parser's CastFailureMode throughout and drop the mz_expr copy. - Document the chain shape a cast template needs for TRY_CAST. - Pin how TRY_CAST plans with EXPLAIN over columns: a single fallible stage, an infallible cast, multi-stage chains, and the optimized plan. - Move the adapter's TRY_CAST tests into catalog/try_cast_tests.rs. - Put the refused-cast remedy in a hint, and log a swallowed internal error in production instead of staying silent. - Mark TRY_CAST as added in v26.46, and drop SqlScalarType::contains, whose only caller was the "char" exclusion the property test no longer needs. - Internal errors swallowed by TRY_CAST log only once. - API improvements. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…es (#39898) Follow-up docs for `TRY_CAST` (#38845, #39834). - **Release gating** - `TRY_CAST` ships in v26.47, not v26.46. Its `version-added` in the function list now says so, so the list shows it as unreleased until then. - The `TRY_CAST` section of the CAST page, already live, gets an unreleased warning. - **Why `TRY_CAST` matters** - The CAST page says what a failing cast does: in a materialized view or an indexed view, one bad row fails every query against the view until the row is gone. - **Pointers from error-prone casts** - The Kafka JSON format details, the webhook JSON parsing section, and the sensitive-columns pattern suggest `TRY_CAST` and link to its section, which carries the unreleased warning. Pages to review: - https://preview.materialize.com/materialize/39898/sql/functions/cast/#try_cast - https://preview.materialize.com/materialize/39898/sql/create-source/kafka/#format-json - https://preview.materialize.com/materialize/39898/sql/create-source/webhook/#json-parsing - https://preview.materialize.com/materialize/39898/security/patterns/protect-sensitive-columns/#upstream-errors-reach-the-exposed-view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Motivation
Closes https://linear.app/materializeinc/issue/SQL-717/safe-cast-try-cast-function-for-handling-bad-data-on-ingest.
Description
A
TRY_CAST(<expr> AS <type>)evaluates<expr>once and attempts to cast it to<type>. While casts can fail (e.g., not every string is an integer!),TRY_CASTwill detect failures and produceNULLinstead.Advice for reviewers
There are just a few core design decisions:
regclass,mz_aclitem) that are implemented in SQL.NULL.UnaryFuncwrapper, with some mild shenanigans in HIR to deconstruct and reconstruct casts.I've maintained semantic commits, so it will be notably easier to go commit-by-commit here.
As follow-up work, we should extend some testing artifacts:
Follow-up PRs we should do:
try_cast({} as T)templates for a few fallible targets in expression.py)I did not implement
DEFAULT ... ON CONVERSION ERROR, which is a nice feature to have. It would not be a hard extension to make.Verification
Extensive testing for each cast: good values stay good, bad values turn into NULL. This testing is in SLTs, proptests, and concrete unit tests with chosen values.