Skip to content

Add Implementation.list_expr/6 so adapters can render list literals - #247

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
AlanMcCann:feat/list-expr-implementation-callback
Aug 16, 2026
Merged

Add Implementation.list_expr/6 so adapters can render list literals#247
zachdaniel merged 1 commit into
ash-project:mainfrom
AlanMcCann:feat/list-expr-implementation-callback

Conversation

@AlanMcCann

Copy link
Copy Markdown
Contributor

Closes #246.

The bug

AshSql.Expr.encode_list/6 renders every list literal that needs encoding as Postgres
ARRAY[...] (or array_to_json(ARRAY[...])), unconditionally. On a data layer whose database
has no array constructor, the statement does not parse. On SQLite:

** (Exqlite.Error) near "[?,?]": syntax error
UPDATE "visual_machines" AS v0
SET "updated_at" = (CASE WHEN ((ARRAY[?,?] IS DISTINCT FROM v0."nodes") OR ...) THEN ... END),
    "nodes" = ARRAY[?,?],
    "sync_status" = ?
WHERE ...

An adapter can already intercept most expressions with its own expr/6 clause. It cannot
intercept this one: default_dynamic_expr/6 routes a bare list to list_expr/6 without
consulting bindings.sql_behaviour.expr/6 first, so sql_behaviour never sees the list. There
is no seam to implement.

This is independent of atomicity. The plain SET col = ... clause is rendered the same way.

The fix

A new AshSql.Implementation callback, list_expr/6, with the same shape and return contract as
expr/6:

@callback list_expr(Ecto.Query.t(), list(), map, boolean, AshSql.Expr.ExprInfo.t(), term) ::
            {:ok, term, AshSql.Expr.ExprInfo.t()} | {:error, term} | :error

__using__ defines it as :error and marks it overridable, exactly as it does for expr/6, so
an implementation that does not override it renders as it did before.

encode_list/6 consults it, and falls back to the existing ARRAY[...] body when it returns
:error. That body is moved verbatim into a private default_encode_list/7; the tokens are
unchanged, so ash_postgres, which does not define list_expr/6, produces byte-identical SQL.

The callback is placed after the embedded-resource dump inside encode_list/6 rather than at the
top, for two reasons. It is the exact point where ARRAY[...] would otherwise be emitted, so
nothing 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/6 calls encode_list/6 when
list_requires_encoding?/1 is true, and handle_literal/6 does the same, so the bare-list path
through default_dynamic_expr/6 is covered. The other branch of list_expr/6, which builds a
comma-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/6 implementation in ash_sqlite
that emits json_array(...):

  • 18 (resource, update action, {:array, :map} attribute) triples across 8 resources went from
    raising to executing. Each is exercised against a real database rather than verified by reading
    the code.
  • Value assertions: the list is written through the update action, read back from the database
    and compared equal, on two unrelated resources.
  • Byte identity: the column written by an UPDATE, which now goes through the callback, holds
    exactly the same bytes as the column written by a CREATE, which goes through Ecto's adapter
    dumper.
  • Writing the same list does not bump updated_at, and a real change still does. That is the
    assertion that would catch a rendering that round-trips but does not compare.
  • Reverting the ash_sqlite implementation, so the default clause takes over again, turns the
    gate red and names all 18. Restoring it turns it green.

For the Postgres side the claim is narrower and mechanical: ash_postgres contains no reference
to list_expr, so it takes the default :error clause into a body whose tokens are identical to
the current one.

Note

ash_sqlite needs the companion list_expr/6 implementation to benefit, and that change is
stacked 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.

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
zachdaniel merged commit 600b5ce into ash-project:main Aug 16, 2026
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)
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.

No adapter seam for list literals: encode_list/6 always renders Postgres ARRAY[...], which is a syntax error on SQLite

2 participants