Add Implementation.list_expr/6 so adapters can render list literals - #247
Merged
zachdaniel merged 1 commit intoAug 16, 2026
Merged
Conversation
encode_list/6 renders every list literal as Postgres ARRAY[...] (or array_to_json(ARRAY[...])) unconditionally, and default_dynamic_expr/6 routes bare lists to it without consulting sql_behaviour, so a data layer whose database has no array constructor cannot intercept: on SQLite the statement fails to parse ((Exqlite.Error) near "[?]": syntax error). Add a list_expr/6 callback with the same shape and return contract as expr/6, defined as :error and overridable in __using__ exactly like expr/6. encode_list/6 consults it and falls back to the existing ARRAY[...] body, moved verbatim into a private default_encode_list/7, so an implementation that does not override it renders byte-identical SQL. The callback sits after the embedded-resource dump, at the exact point ARRAY[...] would otherwise be emitted, so implementations receive a plain list and nothing that renders correctly today is diverted. Closes ash-project#246.
zachdaniel
pushed a commit
to ash-project/ash_sqlite
that referenced
this pull request
Aug 16, 2026
…y(...) (#221) SQLite has no array constructor, so AshSql's ARRAY[...] rendering of list literals fails to parse ((Exqlite.Error) near "[?]": syntax error) for any {:array, :map} attribute. Implement the AshSql.Implementation.list_expr/6 callback (ash-project/ash_sql#247) to render json_array(...) with each element bound as its JSON encoding, matching what Ecto's SQLite adapter dumps for the same column so UPDATE-written and CREATE-written values are byte-identical and update_timestamp's only-bump-if-changed comparison stays honest. Depends on ash-project/ash_sql#247; stacked on #220. * Require ash_sql >= 0.6.9 (Implementation.list_expr/6)
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 #246.
The bug
AshSql.Expr.encode_list/6renders every list literal that needs encoding as PostgresARRAY[...](orarray_to_json(ARRAY[...])), unconditionally. On a data layer whose databasehas no array constructor, the statement does not parse. On SQLite:
An adapter can already intercept most expressions with its own
expr/6clause. It cannotintercept this one:
default_dynamic_expr/6routes a bare list tolist_expr/6withoutconsulting
bindings.sql_behaviour.expr/6first, sosql_behaviournever sees the list. Thereis no seam to implement.
This is independent of atomicity. The plain
SET col = ...clause is rendered the same way.The fix
A new
AshSql.Implementationcallback,list_expr/6, with the same shape and return contract asexpr/6:__using__defines it as:errorand marks it overridable, exactly as it does forexpr/6, soan implementation that does not override it renders as it did before.
encode_list/6consults it, and falls back to the existingARRAY[...]body when it returns:error. That body is moved verbatim into a privatedefault_encode_list/7; the tokens areunchanged, so
ash_postgres, which does not definelist_expr/6, produces byte-identical SQL.The callback is placed after the embedded-resource dump inside
encode_list/6rather than at thetop, for two reasons. It is the exact point where
ARRAY[...]would otherwise be emitted, sonothing that renders correctly today is diverted. And the implementation receives a plain list
rather than a list of embedded structs, so it does not have to repeat the dumping.
Both call sites reach it.
list_expr/6callsencode_list/6whenlist_requires_encoding?/1is true, andhandle_literal/6does the same, so the bare-list paththrough
default_dynamic_expr/6is covered. The other branch oflist_expr/6, which builds acomma-separated parameter list for
IN, emits no array syntax and is deliberately left alone.Testing
Verified against a real application on ash 3.31.3 / ash_sql 0.6.8 / ash_sqlite 0.2.17 /
exqlite 0.36.0 (SQLite 3.51.3), with a companion
list_expr/6implementation inash_sqlitethat emits
json_array(...):{:array, :map}attribute) triples across 8 resources went fromraising to executing. Each is exercised against a real database rather than verified by reading
the code.
and compared equal, on two unrelated resources.
exactly the same bytes as the column written by a CREATE, which goes through Ecto's adapter
dumper.
updated_at, and a real change still does. That is theassertion that would catch a rendering that round-trips but does not compare.
ash_sqliteimplementation, so the default clause takes over again, turns thegate red and names all 18. Restoring it turns it green.
For the Postgres side the claim is narrower and mechanical:
ash_postgrescontains no referenceto
list_expr, so it takes the default:errorclause into a body whose tokens are identical tothe current one.
Note
ash_sqliteneeds the companionlist_expr/6implementation to benefit, and that change isstacked on ash-project/ash_sqlite#220. It is not required for this PR to be correct: without any
implementation, this is a pure refactor plus a new optional seam.