Skip to content

Handle is_distinct_from / is_not_distinct_from in the map-comparison path - #220

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
AlanMcCann:fix/is-distinct-from-map-comparison
Aug 16, 2026
Merged

Handle is_distinct_from / is_not_distinct_from in the map-comparison path#220
zachdaniel merged 1 commit into
ash-project:mainfrom
AlanMcCann:fix/is-distinct-from-map-comparison

Conversation

@AlanMcCann

Copy link
Copy Markdown
Contributor

The bug

An update that Ash runs atomically, on a resource with update_timestamp(:updated_at), writing a
map-typed attribute that is allow_nil? true, fails on SQLite:

** (Exqlite.Error) unsupported type: %{"enabled" => "false", "v2" => "true"}
UPDATE "rag_archives" AS r0
SET "updated_at" = (CASE WHEN (? IS DISTINCT FROM r0."custom_settings")
                         THEN CAST(? AS TEXT)
                         ELSE CAST(r0."updated_at" AS TEXT) END),
    "custom_settings" = ?
WHERE ...

The same update against an otherwise identical attribute that is allow_nil? false succeeds.

Why

Ash.Changeset.atomic_default_condition/4 builds the "only bump the timestamp if something
actually changed" condition. If the changed attribute can be nil it emits
is_distinct_from(^new_value, ^ref(key)); if it cannot it emits ^new_value != ^ref(key).

v0.2.15 added JSON-encoded map comparison ("handle map comparisons via json encoding") to
AshSqlite.SqlImplementation.expr/6, but only for Ash.Query.Operator.Eq and
Ash.Query.Operator.NotEq. Ash.Query.Function.IsDistinctFrom has no clause, so the expression
falls through to the generic AshSql.Expr renderer. There,
AshSqlite.SqlImplementation.parameterized_type/2 deliberately returns nil for Ash.Type.Map,
so maybe_type_expr/6 produces no cast and the raw Elixir map is bound as a driver parameter.
Exqlite cannot encode a bare map and rejects it.

So allow_nil? is the entire discriminator, on one resource, one action, two attributes of the
same type:

attribute allow_nil? operator Ash emits result
value false != works (handled since v0.2.15)
metadata true is_distinct_from (Exqlite.Error) unsupported type

Minimal repro

defmodule Repro.Thing do
  use Ash.Resource, domain: Repro.Domain, data_layer: AshSqlite.DataLayer

  sqlite do
    table "things"
    repo Repro.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :required_map, :map, allow_nil?: false, default: %{}, public?: true
    attribute :optional_map, :map, allow_nil?: true, public?: true
    update_timestamp :updated_at          # the lazy update_default is what builds the condition
  end

  actions do
    defaults [:read, :create]
    update :update, primary?: true, accept: [:required_map, :optional_map]
  end
end

{:ok, thing} = Ash.create(Repro.Thing, %{})

# works
{:ok, _} = Ash.update(thing, %{required_map: %{"a" => 1}})

# raises: ** (Exqlite.Error) unsupported type: %{"a" => 1}
{:ok, _} = Ash.update(thing, %{optional_map: %{"a" => 1}})

Remove update_timestamp :updated_at and both succeed, because Ash never builds the condition.

The fix

Two expr/6 clauses for IsDistinctFrom / IsNotDistinctFrom that mirror the existing Eq /
NotEq ones exactly, routing to handle_map_comparison/9, plus two cases in that function
rendering IS DISTINCT FROM / IS NOT DISTINCT FROM. SQLite has supported those operators since
3.39.

The guard is the same is_non_struct_map(left) or is_non_struct_map(right) used by the clauses
above, so no comparison that does not involve a plain map changes behaviour.

IS DISTINCT FROM remains correct against the JSON encoding: the literal side becomes a JSON
string and the ref side becomes json(col), and json(NULL) is NULL, so a NULL column is
correctly "distinct from" any map.

Testing

Verified against a real application on ash 3.31.3 / ash_sqlite 0.2.17 / exqlite 0.36.0
(SQLite 3.51.3):

  • 49 (resource, update action, nullable map attribute) triples across ~28 resources went from
    raising to executing. Each is exercised against a real database rather than verified by
    reading the code.
  • Value assertions on two unrelated resources: the map is written, read back from the database and
    compared equal.
  • The allow_nil? false control still passes, so the pre-existing != branch is untouched.
  • Writing nil still clears the column, so nil and %{} remain distinguishable.
  • Writing the same map does not bump updated_at, which is the behaviour the condition exists
    to provide. This is the assertion that would catch a fix that simply forced the comparison true.
  • Reverting the patch turns the gate red and names all 49.

Out of scope (separate defect, reported for completeness)

{:array, :map} attributes are also broken on SQLite, but for a different reason and with a
different signature:

** (Exqlite.Error) near "[?]": syntax error
UPDATE "visual_machines" AS v0 SET ..., "connections" = ARRAY[?], "nodes" = ARRAY[?,?]

AshSql.Expr.encode_list/6 renders every list literal as Postgres ARRAY[...] (or
array_to_json(ARRAY[...])) with no adapter seam. That is in ash_sql, affects the plain
SET clause as well, and is unrelated to is_distinct_from, so it is not addressed here. Happy
to open a separate issue on ash_sql if useful; it likely needs a new
AshSql.Implementation callback so adapters can render list literals themselves.

…path

Ash emits is_distinct_from in place of != whenever either side of the
comparison can be nil, which is exactly what update_timestamp's
only-bump-if-changed condition builds for any nullable :map attribute.
The v0.2.15 JSON-encoded map comparison only matched Eq/NotEq, so the
nullable case fell through to the generic renderer, bound the raw
Elixir map as a driver parameter, and Exqlite rejected it:
(Exqlite.Error) unsupported type: %{...}.

Mirror the Eq/NotEq clauses for IsDistinctFrom/IsNotDistinctFrom,
routing to handle_map_comparison/9, and render IS DISTINCT FROM /
IS NOT DISTINCT FROM (supported by SQLite since 3.39). The
is_non_struct_map guard is unchanged, so no comparison not involving a
plain map changes behaviour. json(NULL) is NULL, so a NULL column
remains correctly distinct from any map, and writing the same map
still does not bump updated_at.
@zachdaniel

Copy link
Copy Markdown
Contributor

PR welcome to resolve this 🙇

@zachdaniel

Copy link
Copy Markdown
Contributor

The stated fix sounds good 👍

@zachdaniel

Copy link
Copy Markdown
Contributor

lol, this is a PR 😆

@zachdaniel
zachdaniel merged commit 3e2542f into ash-project:main Aug 16, 2026
47 of 51 checks passed
@zachdaniel

Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

@AlanMcCann

Copy link
Copy Markdown
Contributor Author

I appreciate the fast turnaround on the review... do you ever sleep? On the out-of-scope part: it's filed as ash_sql #246 and I have the callback implementation in progress, so a PR for that is coming shortly.

zachdaniel pushed a commit 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)
zachdaniel pushed a commit that referenced this pull request Aug 17, 2026
…222)

A map anywhere in an expression other than as a comparison operand
(v0.2.15/#220) or a list element (#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 #221, so a fresh clone can run
the suite again.

Closes #219.
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.

2 participants