JSON-encode map literals wherever they appear in an expression - #222
Merged
zachdaniel merged 1 commit intoAug 17, 2026
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
The same thing on the atomic update path, which is how #219 was originally hit:
Three shapes reach it, and none of them is exotic: a map used as a
fragmentargument, a map used as a branch of an
if, and a map nested inside a booleanexpression. All three are in the new test file.
Why #220 and #221 did not cover this
They each fixed one position, not the value.
==,!=,is_distinct_from, oris_not_distinct_from. It matches on thecomparison struct, so it only ever sees the map when the map is directly one of
the two sides.
list_expr/6.A map anywhere else never meets either clause. It falls through
AshSqlite.SqlImplementation.expr/6to AshSql, and AshSql renders a plain mapspecially only in two cases: inside a
selectsub-expression, or inside anupdate/aggregatewhen the map itself CONTAINS an expression. Every otherposition lands on the branch that returns the map unchanged, and it becomes a
bound parameter.
parameterized_type/2deliberately returnsnilforAsh.Type.Map, so there is no Ecto type to dump it either, and exqlite is rightto refuse it.
Worth recording for #219 specifically: the exact SQL in the original report is
already fixed, by #220. That report was a nullable
:mapattribute whoseupdate_timestampcondition emittedis_distinct_from, which is now an operandof a handled comparison. What was left is the general class, which this closes.
The fix
One
expr/6clause for a plain map, placed just before the catch-all, giving themap the same treatment
handle_map_comparison/9already gives the same value. Itcalls the existing
as_json/7, so there is exactly one behaviour for a map inthis data layer rather than two that could drift:
Jason.encode!/1is what Ecto's SQLite adapter dumps for a:mapcolumn, so amap 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:
encoded, and AshSql already has handling for it.
location == :select, where AshSql builds a map of dynamics rather than asingle 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.Maphas no parameterized type here, andEcto's SQLite adapter already stores a
:mapcolumn as JSON text, so keysalready 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:
That is
Ash.Changeset.do_atomic_update/4declining before any SQL is built, andit 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 viaecto_sqlite3.
Baseline before the change:
The new
test/map_expr_test.exswith the fix reverted, showing it is a realdefect and the test catches it. The one that passes is the control, a map
compared directly against a map attribute, which #220 already covers:
Full suite with the fix, 160 existing plus the 5 new:
mix credo:One unrelated thing in the diff
mix.lockmoves ash_sql 0.6.6 to 0.6.9.mix.exshas required>= 0.6.9since#221, but the committed lock still pinned 0.6.6, so a fresh clone of
maincannot run the suite:
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.