Skip to content

JSON-encode map literals wherever they appear in an expression - #222

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
AlanMcCann:fix/map-literal-json-bind
Aug 17, 2026
Merged

JSON-encode map literals wherever they appear in an expression#222
zachdaniel merged 1 commit into
ash-project:mainfrom
AlanMcCann:fix/map-literal-json-bind

Conversation

@AlanMcCann

Copy link
Copy Markdown
Contributor

Closes #219. This is the follow-up you asked for on the issue: doing for maps
generally what #220 and #221 did for map comparisons and list literals.

The bug

A map that appears anywhere in an expression other than as an operand of a
comparison, or as an element of a list, is handed to the driver as a bare Elixir
term, and rejected:

** (Exqlite.Error) unsupported type: %{"k" => "v"}
SELECT d0."id", d0."name", d0."status", d0."entity", d0."inserted_at", d0."updated_at"
FROM "devices" AS d0 WHERE ((json(d0."entity") = json(?)))

The same thing on the atomic update path, which is how #219 was originally hit:

** (Exqlite.Error) unsupported type: %{"k" => "v"}
UPDATE "devices" AS d0 SET "updated_at" = (CASE WHEN (CAST(? AS TEXT) != json(d0."entity"))
THEN CAST(? AS TEXT) ELSE CAST(d0."updated_at" AS TEXT) END), "entity" = ?
WHERE ((json(d0."entity") = json(?)))

Three shapes reach it, and none of them is exotic: a map used as a fragment
argument, a map used as a branch of an if, and a map nested inside a boolean
expression. All three are in the new test file.

Why #220 and #221 did not cover this

They each fixed one position, not the value.

A map anywhere else never meets either clause. It falls through
AshSqlite.SqlImplementation.expr/6 to AshSql, and AshSql renders a plain map
specially only in two cases: inside a select sub-expression, or inside an
update / aggregate when the map itself CONTAINS an expression. Every other
position lands on the branch that returns the map unchanged, and it becomes a
bound parameter. parameterized_type/2 deliberately returns nil for
Ash.Type.Map, so there is no Ecto type to dump it either, and exqlite is right
to refuse it.

Worth recording for #219 specifically: the exact SQL in the original report is
already fixed, by #220. That report was a nullable :map attribute whose
update_timestamp condition emitted is_distinct_from, which is now an operand
of a handled comparison. What was left is the general class, which this closes.

The fix

One expr/6 clause for a plain map, placed just before the catch-all, giving the
map the same treatment handle_map_comparison/9 already gives the same value. It
calls the existing as_json/7, so there is exactly one behaviour for a map in
this data layer rather than two that could drift:

def expr(query, value, bindings, embedded?, acc, type)
    when is_non_struct_map(value) do
  if bindings[:location] == :select or Ash.Expr.expr?(value) do
    :error
  else
    {expr, acc} = as_json(query, value, false, bindings, embedded?, acc, type)

    {:ok, expr, acc}
  end
end

Jason.encode!/1 is what Ecto's SQLite adapter dumps for a :map column, so a
map rendered here is byte-identical to the same map stored by an INSERT. That is
what keeps a comparison against the column meaningful, and it is the same
reasoning as #221's element encoding.

Two cases are deliberately left to AshSql, so nothing that works today changes:

  • A map that CONTAINS an expression. Its values are not data and cannot be
    encoded, and AshSql already has handling for it.
  • location == :select, where AshSql builds a map of dynamics rather than a
    single value. Intercepting that would turn a returned map into a JSON string.

On atom keys

You flagged that Postgres can keep atom keys on write while the read-back
stringifies them anyway. Agreed that it is not a big deal, and on this data layer
it is not a change at all. Ash.Type.Map has no parameterized type here, and
Ecto's SQLite adapter already stores a :map column as JSON text, so keys
already come back as strings today, independent of this patch. What this changes
is that the expression path now agrees with the storage path instead of failing.
No new stringification is introduced.

Not this patch

Two neighbouring shapes fail inside Ash rather than here, so they are out of
scope and unaffected:

Cannot atomically update AshSqlite.Test.Device.entity:
Type `Ash.Type.Map` does not support atomic updates with expressions

That is Ash.Changeset.do_atomic_update/4 declining before any SQL is built, and
it is what you hit if you try atomic_update(:entity, expr(...)) with a map.
Mentioning it only so the boundary is clear.

Testing

Base main @ efea598, Elixir 1.20.1, ash 3.31.3, ash_sql 0.6.9, exqlite via
ecto_sqlite3.

Baseline before the change:

Finished in 22.9 seconds (1.0s async, 21.9s sync)
Result: 160 passed

The new test/map_expr_test.exs with the fix reverted, showing it is a real
defect and the test catches it. The one that passes is the control, a map
compared directly against a map attribute, which #220 already covers:

  1) test a map literal used as a fragment argument is bound as JSON (AshSqlite.MapExprTest)
     * ** (Exqlite.Error) unsupported type: %{"k" => "v"}
  2) test a map literal nested in a boolean expression is bound as JSON (AshSqlite.MapExprTest)
     * ** (Exqlite.Error) unsupported type: %{"k" => "v"}
  3) test a map literal used as a branch of an if is bound as JSON (AshSqlite.MapExprTest)
     * ** (Exqlite.Error) unsupported type: %{}
  4) test an atomic update filtered by an expression containing a map literal runs (AshSqlite.MapExprTest)
     * ** (Exqlite.Error) unsupported type: %{"k" => "v"}
Result: 1/5 passed

Full suite with the fix, 160 existing plus the 5 new:

Finished in 29.0 seconds (2.9s async, 26.0s sync)
Result: 165 passed

mix credo:

674 mods/funs, found no issues.

One unrelated thing in the diff

mix.lock moves ash_sql 0.6.6 to 0.6.9. mix.exs has required >= 0.6.9 since
#221, but the committed lock still pinned 0.6.6, so a fresh clone of main
cannot run the suite:

Unchecked dependencies for environment test:
* ash_sql (Hex package)
  lock mismatch: the dependency is out of date. To fetch locked version run "mix deps.get"

Happy to drop that hunk if you would rather refresh the lock separately.

With this and the two merged fixes, the map story on this data layer is closed as
far as I can find.

A map anywhere in an expression other than as a comparison operand
(v0.2.15/ash-project#220) or a list element (ash-project#221) was bound as a bare Elixir
term and rejected by exqlite (unsupported type). One expr/6 clause for
a plain map, just before the catch-all, delegating to the existing
as_json/7 so a map has one behaviour in this data layer. Maps that
contain expressions and the :select location stay with AshSql, so
nothing that works today changes. Also bumps mix.lock's ash_sql to the
0.6.9 that mix.exs has required since ash-project#221, so a fresh clone can run
the suite again.

Closes ash-project#219.
@zachdaniel
zachdaniel merged commit b26e826 into ash-project:main Aug 17, 2026
44 of 51 checks passed
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.

Atomic updates bind :map attributes as raw Elixir maps; exqlite rejects them ("unsupported type")

2 participants