Skip to content

fix: convert Ecto.Query.CastError on every query path - #864

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
grempe:fix/cast-error-conversion
Sep 22, 2026
Merged

zachdaniel merged 1 commit into
ash-project:mainfrom
grempe:fix/cast-error-conversion

Conversation

@grempe

@grempe grempe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Closes #855.

Ash does not cast filter values against the attribute type (Eq declares types: [:any, :same]), so a value that cannot be cast reaches Ecto, which raises Ecto.Query.CastError while planning the query. run_query/2 rescues that and converts it to Ash.Error.Query.InvalidFilterValue, but the conversion had two gaps, all with a UUID primary key and id == "not-a-uuid":

  • run_aggregate_query/3, run_aggregate_query_with_lateral_join/5 and run_query_with_lateral_join/4 have no rescue, so Ash.count/2, Ash.exists/2, Ash.aggregate/3 and a limited relationship load across several parents raise the exception through the caller. The Ash.DataLayer behaviour declares {:ok, _} | {:error, _} for all four callbacks.
  • handle_raised_error/4 does not unwrap Ecto.SubQueryError, which Ecto raises around a cast error inside a subquery and which keeps the original in exception. So a count under a limit, a paginated read with a relationship filter, and update_query/4 (AshJsonApi's PATCH /posts/not-a-uuid) return Ash.Error.Unknown even where a rescue exists.

Changes, all in AshPostgres.DataLayer:

  • A handle_raised_error/4 clause for %Ecto.SubQueryError{exception: inner} that re-dispatches the inner exception, so it converts the same way as at the top level.
  • A rescue on the three callbacks, calling handle_raised_error/4 with the query and resource the way run_query/2 already does. run_aggregate_query/3 delegates to AshSql.AggregateQuery; the rescue is placed here rather than in ash_sql because the other rescues and the conversion live here. run_query_with_lateral_join/4 previously ignored its destination_resource argument; it is now used for the error.

Tests: test/cast_error_test.exs covers count, exists and aggregate (raised CastError on main), a count under a limit (raised SubQueryError), a limited relationship load across two parents and a relationship filter on a paginated read (both Ash.Error.Unknown on main), plus a control with a valid id on every path. Full suite 1006 passed on main, where the same three JoinSubquerySortTest / UniqAggregateSortTest cases from #858 fail with and without this change, and 983 passed with the patch on v2.13.1. mix format --check-formatted, mix credo --strict, mix sobelow and mix dialyzer clean.

Overlap with #863. That PR adds a clause to handle_raised_error/4 at the same spot as the SubQueryError clause here, so whichever merges second will conflict on that one hunk. The resolution is to keep both clauses (order does not matter, they match different structs). I merged the two branches locally that way, and the full suite plus both new test files pass on the result. Happy to rebase this one once #863 lands, or the other way round.

Found by an AI agent working with a human fuzz-testing their own application; the reproduction outside this repo is https://github.com/grempe/ash-fuzz-repros/blob/main/test/ash_postgres/uncastable_filter_value_conversion_test.exs, which also covers the ash_lua, AshGraphql and AshJsonApi surfaces of the same paths.

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

Ash does not cast filter values against the attribute type, so a value
that cannot be cast reaches Ecto, which raises `Ecto.Query.CastError`
while planning the query. `run_query/2` rescues that and converts it to
`InvalidFilterValue`, but the conversion had two gaps:

- `run_aggregate_query/3`, `run_aggregate_query_with_lateral_join/5` and
  `run_query_with_lateral_join/4` had no rescue, so `Ash.count`,
  `Ash.exists`, `Ash.aggregate` and a limited relationship load across
  several parents raised instead of returning an error tuple, which the
  `Ash.DataLayer` callbacks declare.
- `handle_raised_error/4` did not unwrap `Ecto.SubQueryError`, so a cast
  error inside a paginated, limited or lateral-join query became
  `Ash.Error.Unknown` even where a rescue existed.

Closes ash-project#855
@zachdaniel
zachdaniel merged commit 909d653 into ash-project:main Sep 22, 2026
@zachdaniel

Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ecto.Query.CastError is converted only where a rescue exists, and Ecto.SubQueryError is never unwrapped

2 participants