diff --git a/lib/extensions/immutable_raise_error.ex b/lib/extensions/immutable_raise_error.ex index a1a4032c..3026602c 100644 --- a/lib/extensions/immutable_raise_error.ex +++ b/lib/extensions/immutable_raise_error.ex @@ -30,25 +30,37 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do ``` """ - use AshPostgres.CustomExtension, name: "immutable_raise_error", latest_version: 1 + use AshPostgres.CustomExtension, name: "immutable_raise_error", latest_version: 2 require Ecto.Query @impl true def install(0) do - ash_raise_error_immutable() + """ + #{ash_raise_error_immutable()} + + #{ash_to_jsonb_immutable()} + """ + end + + def install(1) do + ash_to_jsonb_immutable() end @impl true + def uninstall(2) do + "execute(\"DROP FUNCTION IF EXISTS ash_to_jsonb_immutable(anyelement)\")" + end + def uninstall(_version) do - "execute(\"DROP FUNCTION IF EXISTS ash_raise_error_immutable(jsonb, ANYCOMPATIBLE), ash_raise_error_immutable(jsonb, ANYELEMENT, ANYCOMPATIBLE)\")" + "execute(\"DROP FUNCTION IF EXISTS ash_to_jsonb_immutable(anyelement), ash_raise_error_immutable(jsonb, anycompatible), ash_raise_error_immutable(jsonb, anyelement, anycompatible)\")" end defp ash_raise_error_immutable do """ execute(\"\"\" - CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, token ANYCOMPATIBLE) - RETURNS BOOLEAN AS $$ + CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, token anycompatible) + RETURNS boolean AS $$ BEGIN -- Raise an error with the provided JSON data. -- The JSON object is converted to text for inclusion in the error message. @@ -62,8 +74,8 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do \"\"\") execute(\"\"\" - CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, type_signal ANYELEMENT, token ANYCOMPATIBLE) - RETURNS ANYELEMENT AS $$ + CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, type_signal anyelement, token anycompatible) + RETURNS anyelement AS $$ BEGIN -- Raise an error with the provided JSON data. -- The JSON object is converted to text for inclusion in the error message. @@ -78,60 +90,48 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do """ end + # Wraps to_jsonb and pins session GUCs that affect JSON. This makes the function’s result + # deterministic, so it is safe to mark IMMUTABLE. + defp ash_to_jsonb_immutable do + """ + execute(\"\"\" + CREATE OR REPLACE FUNCTION ash_to_jsonb_immutable(value anyelement) + RETURNS jsonb + LANGUAGE plpgsql + IMMUTABLE + SET search_path TO 'pg_catalog' + SET \"TimeZone\" TO 'UTC' + SET \"DateStyle\" TO 'ISO, YMD' + SET \"IntervalStyle\" TO 'iso_8601' + SET extra_float_digits TO '0' + SET bytea_output TO 'hex' + AS $function$ + BEGIN + RETURN COALESCE(to_jsonb(value), 'null'::jsonb); + END; + $function$ + \"\"\") + """ + end + @doc false def immutable_error_expr( query, %Ash.Query.Function.Error{arguments: [exception, input]} = value, bindings, - embedded?, + _embedded?, acc, type ) do + if !(Keyword.keyword?(input) or is_map(input)) do + raise "Input expression to `error` must be a map or keyword list" + end + acc = %{acc | has_error?: true} - {encoded, acc} = + {error_payload, acc} = if Ash.Expr.expr?(input) do - frag_parts = - Enum.flat_map(input, fn {key, value} -> - if Ash.Expr.expr?(value) do - [ - expr: to_string(key), - raw: "::text, ", - expr: value, - raw: ", " - ] - else - [ - expr: to_string(key), - raw: "::text, ", - expr: value, - raw: "::jsonb, " - ] - end - end) - - frag_parts = - List.update_at(frag_parts, -1, fn {:raw, text} -> - {:raw, String.trim_trailing(text, ", ") <> "))"} - end) - - AshSql.Expr.dynamic_expr( - query, - %Ash.Query.Function.Fragment{ - embedded?: false, - arguments: - [ - raw: "jsonb_build_object('exception', ", - expr: inspect(exception), - raw: "::text, 'input', jsonb_build_object(" - ] ++ - frag_parts - }, - bindings, - embedded?, - nil, - acc - ) + expression_error_payload(exception, input, query, bindings, acc) else {Jason.encode!(%{exception: inspect(exception), input: Map.new(input)}), acc} end @@ -163,7 +163,7 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do {nil, row_token} -> {:ok, Ecto.Query.dynamic( - fragment("ash_raise_error_immutable(?::jsonb, ?)", ^encoded, ^row_token) + fragment("ash_raise_error_immutable(?::jsonb, ?)", ^error_payload, ^row_token) ), acc} {dynamic_type, row_token} -> @@ -171,7 +171,7 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do Ecto.Query.dynamic( fragment( "ash_raise_error_immutable(?::jsonb, ?, ?)", - ^encoded, + ^error_payload, ^dynamic_type, ^row_token ) @@ -179,6 +179,55 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do end end + # Encodes an error payload as jsonb using only IMMUTABLE SQL functions. + # + # Strategy: + # * Split the 'input' into Ash expressions and literal values + # * Build the base json map with the exception name and literal input values + # * For each expression value, use nested calls to `jsonb_set` (IMMUTABLE) to add the value to + # 'input', converting each expression to jsonb using `ash_to_jsonb_immutable` (which pins + # session GUCs for deterministic encoding) + defp expression_error_payload(exception, input, query, bindings, acc) do + {expr_inputs, literal_inputs} = + Enum.split_with(input, fn {_key, value} -> Ash.Expr.expr?(value) end) + + base_json = %{exception: inspect(exception), input: Map.new(literal_inputs)} + + Enum.reduce(expr_inputs, {base_json, acc}, fn + {key, expr_value}, {current_payload, acc} -> + path_expr = %Ash.Query.Function.Type{ + arguments: [["input", to_string(key)], {:array, :string}, []] + } + + new_value_jsonb = + %Ash.Query.Function.Fragment{ + arguments: [raw: "ash_to_jsonb_immutable(", expr: expr_value, raw: ")"] + } + + {%Ecto.Query.DynamicExpr{} = new_payload, acc} = + AshSql.Expr.dynamic_expr( + query, + %Ash.Query.Function.Fragment{ + arguments: [ + raw: "jsonb_set(", + expr: current_payload, + raw: "::jsonb, ", + expr: path_expr, + raw: ", ", + expr: new_value_jsonb, + raw: "::jsonb, true)" + ] + }, + bindings, + false, + nil, + acc + ) + + {new_payload, acc} + end) + end + # Returns a row-dependent token to prevent constant-folding for immutable functions. defp immutable_error_expr_token(query, bindings) do resource = query.__ash_bindings__.resource diff --git a/priv/resource_snapshots/test_repo/extensions.json b/priv/resource_snapshots/test_repo/extensions.json index d1c5a122..35a36695 100644 --- a/priv/resource_snapshots/test_repo/extensions.json +++ b/priv/resource_snapshots/test_repo/extensions.json @@ -6,6 +6,7 @@ "pg_trgm", "citext", "demo-functions_v1", + "immutable_raise_error_v2", "ltree" ] } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/immutable_error_testers/20251015134240.json b/priv/resource_snapshots/test_repo/immutable_error_testers/20251015134240.json new file mode 100644 index 00000000..5917eec5 --- /dev/null +++ b/priv/resource_snapshots/test_repo/immutable_error_testers/20251015134240.json @@ -0,0 +1,226 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "atom_value", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "string_value", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "integer_value", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "float_value", + "type": "float" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "boolean_value", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "struct_value", + "type": "map" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uuid_value", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "date_value", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "time_value", + "type": "time" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ci_string_value", + "type": "citext" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "naive_datetime_value", + "type": "naive_datetime" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "utc_datetime_value", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "timestamptz_value", + "type": "timestamptz" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "string_array_value", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "response_value", + "type": "integer" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "nullable_string_value", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "BB177B855B058F50CA20AFCF6F624072C1395BFDF3FB0B2F60BDC14A15A053F1", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "immutable_error_testers" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/immutable_error_testers/20251015134240.json.license b/priv/resource_snapshots/test_repo/immutable_error_testers/20251015134240.json.license new file mode 100644 index 00000000..b0a44fab --- /dev/null +++ b/priv/resource_snapshots/test_repo/immutable_error_testers/20251015134240.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/priv/test_repo/migrations/20251015134238_install_immutable_raise_error_v2_extension.exs b/priv/test_repo/migrations/20251015134238_install_immutable_raise_error_v2_extension.exs new file mode 100644 index 00000000..eb4a8d77 --- /dev/null +++ b/priv/test_repo/migrations/20251015134238_install_immutable_raise_error_v2_extension.exs @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.TestRepo.Migrations.InstallImmutableRaiseErrorV220251015134237 do + @moduledoc """ + Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + execute(""" + CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, token anycompatible) + RETURNS boolean AS $$ + BEGIN + -- Raise an error with the provided JSON data. + -- The JSON object is converted to text for inclusion in the error message. + -- 'token' is intentionally ignored; its presence makes the call non-constant at the call site. + RAISE EXCEPTION 'ash_error: %', json_data::text; + RETURN NULL; + END; + $$ LANGUAGE plpgsql + IMMUTABLE + SET search_path = ''; + """) + + execute(""" + CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, type_signal anyelement, token anycompatible) + RETURNS anyelement AS $$ + BEGIN + -- Raise an error with the provided JSON data. + -- The JSON object is converted to text for inclusion in the error message. + -- 'token' is intentionally ignored; its presence makes the call non-constant at the call site. + RAISE EXCEPTION 'ash_error: %', json_data::text; + RETURN NULL; + END; + $$ LANGUAGE plpgsql + IMMUTABLE + SET search_path = ''; + """) + + execute(""" + CREATE OR REPLACE FUNCTION ash_to_jsonb_immutable(value anyelement) + RETURNS jsonb + LANGUAGE plpgsql + IMMUTABLE + SET search_path TO 'pg_catalog' + SET "TimeZone" TO 'UTC' + SET "DateStyle" TO 'ISO, YMD' + SET "IntervalStyle" TO 'iso_8601' + SET extra_float_digits TO '0' + SET bytea_output TO 'hex' + AS $function$ + BEGIN + RETURN COALESCE(to_jsonb(value), 'null'::jsonb); + END; + $function$ + """) + end + + def down do + # Uncomment this if you actually want to uninstall the extensions + # when this migration is rolled back: + execute("DROP FUNCTION IF EXISTS ash_to_jsonb_immutable(anyelement)") + end +end diff --git a/priv/test_repo/migrations/20251015134240_migrate_resources63.exs b/priv/test_repo/migrations/20251015134240_migrate_resources63.exs new file mode 100644 index 00000000..0454f8a4 --- /dev/null +++ b/priv/test_repo/migrations/20251015134240_migrate_resources63.exs @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.TestRepo.Migrations.MigrateResources63 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:immutable_error_testers, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:atom_value, :text, null: false) + add(:string_value, :text, null: false) + add(:integer_value, :bigint, null: false) + add(:float_value, :float, null: false) + add(:boolean_value, :boolean, null: false) + add(:struct_value, :map, null: false) + add(:uuid_value, :uuid, null: false) + add(:date_value, :date, null: false) + add(:time_value, :time, null: false) + add(:ci_string_value, :citext, null: false) + add(:naive_datetime_value, :naive_datetime, null: false) + add(:utc_datetime_value, :utc_datetime, null: false) + add(:timestamptz_value, :timestamptz, null: false) + add(:string_array_value, {:array, :text}, null: false) + add(:response_value, :integer, null: false) + add(:nullable_string_value, :text) + end + end + + def down do + drop(table(:immutable_error_testers)) + end +end diff --git a/test/immutable_raise_error_test.exs b/test/immutable_raise_error_test.exs new file mode 100644 index 00000000..076efeaf --- /dev/null +++ b/test/immutable_raise_error_test.exs @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.ImmutableRaiseErrorTest do + use AshPostgres.RepoCase, async: false + + alias AshPostgres.Test.ImmutableErrorTester + + require Ash.Query + + setup do + original = Application.get_env(:ash_postgres, :test_repo_use_immutable_errors?) + Application.put_env(:ash_postgres, :test_repo_use_immutable_errors?, true) + + on_exit(fn -> + if is_nil(original) do + Application.delete_env(:ash_postgres, :test_repo_use_immutable_errors?) + else + Application.put_env(:ash_postgres, :test_repo_use_immutable_errors?, original) + end + end) + + :ok + end + + describe "atomic error payloads" do + test "update_one returns InvalidAttribute error with expression value" do + tester = create_tester() + + # The :update_one validation builds an error with a single expression value and literal + # values (non-empty base input). + assert {:error, %Ash.Error.Invalid{errors: [error]}} = + tester + |> Ash.Changeset.for_update(:update_one, %{integer_value: 99}) + |> Ash.update() + + assert %Ash.Error.Changes.InvalidAttribute{} = error + assert error.field == :integer_value + assert error.value == 99 + end + + test "update_many returns custom error containing all expression values" do + tester = create_tester() + + # The :update_many validation builds an error that include many (all attributes) value + # expressions, and zero literal values (empty base input). + assert {:error, %Ash.Error.Invalid{errors: [error]}} = + tester + |> Ash.Changeset.for_update(:update_many, %{}) + |> Ash.update() + + assert %ImmutableErrorTester.Error{} = error + + assert error.atom_value == "initial_atom" + assert error.string_value == "initial string" + assert error.integer_value == 10 + assert error.float_value == 1.5 + assert error.boolean_value == true + + assert error.struct_value == %{ + "active?" => true, + "count" => 1, + "name" => "initial" + } + + assert error.uuid_value == "00000000-0000-0000-0000-000000000000" + assert error.date_value == "2024-01-01" + assert error.time_value == "12:00:00" + assert error.ci_string_value == "Initial String" + assert error.naive_datetime_value == "2024-01-01T12:00:00" + assert error.utc_datetime_value == "2024-01-01T00:01:00" + assert error.timestamptz_value == "2024-01-01T00:02:00+00:00" + assert error.string_array_value == ["one", "two"] + + # Native value for :awaiting is 0 + assert error.response_value == 0 + assert error.nullable_string_value == nil + end + + test "update_literal returns literal payload" do + tester = create_tester() + + # The :update_literal validation builds an error with only literal values, zero expression values. + assert {:error, %Ash.Error.Invalid{errors: [error]}} = + tester + |> Ash.Changeset.for_update(:update_literal, %{}) + |> Ash.update() + + assert error.string_value == "literal string" + assert error.integer_value == 123 + assert error.float_value == 9.99 + assert error.boolean_value == false + assert error.string_array_value == ["alpha", "beta"] + assert error.nullable_string_value == nil + end + end + + defp create_tester do + input = + %{ + atom_value: :initial_atom, + string_value: "initial string", + integer_value: 10, + float_value: 1.5, + boolean_value: true, + struct_value: ImmutableErrorTester.Struct.new!(name: "initial", count: 1, active?: true), + uuid_value: "00000000-0000-0000-0000-000000000000", + date_value: ~D[2024-01-01], + time_value: ~T[12:00:00], + ci_string_value: "Initial String", + naive_datetime_value: ~N[2024-01-01 12:00:00], + utc_datetime_value: ~U[2024-01-01 00:01:00.00Z], + timestamptz_value: ~U[2024-01-01 00:02:00.00Z], + string_array_value: ["one", "two"], + response_value: :awaiting, + nullable_string_value: nil + } + + ImmutableErrorTester + |> Ash.Changeset.for_create(:create, input) + |> Ash.create!() + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index 2fb81323..8e9a19d3 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -58,6 +58,7 @@ defmodule AshPostgres.Test.Domain do resource(AshPostgres.Test.Chat) resource(AshPostgres.Test.Message) resource(AshPostgres.Test.RSVP) + resource(AshPostgres.Test.ImmutableErrorTester) end authorization do diff --git a/test/support/resources/immutable_error_tester/error.ex b/test/support/resources/immutable_error_tester/error.ex new file mode 100644 index 00000000..15dca8ee --- /dev/null +++ b/test/support/resources/immutable_error_tester/error.ex @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ImmutableErrorTester.Error do + @moduledoc false + use Splode.Error, + class: :invalid, + fields: [ + :atom_value, + :string_value, + :integer_value, + :float_value, + :boolean_value, + :struct_value, + :uuid_value, + :date_value, + :time_value, + :ci_string_value, + :naive_datetime_value, + :utc_datetime_value, + :timestamptz_value, + :string_array_value, + :response_value, + :nullable_string_value + ] + + def message(_error) do + "Immutable Error" + end +end diff --git a/test/support/resources/immutable_error_tester/immutable_error_tester.ex b/test/support/resources/immutable_error_tester/immutable_error_tester.ex new file mode 100644 index 00000000..50ec1613 --- /dev/null +++ b/test/support/resources/immutable_error_tester/immutable_error_tester.ex @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ImmutableErrorTester do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + require Ash.Expr + import Ash.Expr + + postgres do + table "immutable_error_testers" + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + + attribute(:atom_value, :atom, allow_nil?: false, public?: true) + attribute(:string_value, :string, allow_nil?: false, public?: true) + attribute(:integer_value, :integer, allow_nil?: false, public?: true) + attribute(:float_value, :float, allow_nil?: false, public?: true) + attribute(:boolean_value, :boolean, allow_nil?: false, public?: true) + + attribute(:struct_value, AshPostgres.Test.ImmutableErrorTester.Struct, + allow_nil?: false, + public?: true + ) + + attribute(:uuid_value, Ash.Type.UUID, allow_nil?: false, public?: true) + attribute(:date_value, :date, allow_nil?: false, public?: true) + attribute(:time_value, :time, allow_nil?: false, public?: true) + attribute(:ci_string_value, :ci_string, allow_nil?: false, public?: true) + attribute(:naive_datetime_value, :naive_datetime, allow_nil?: false, public?: true) + attribute(:utc_datetime_value, :utc_datetime, allow_nil?: false, public?: true) + attribute(:timestamptz_value, AshPostgres.Timestamptz, allow_nil?: false, public?: true) + attribute(:string_array_value, {:array, :string}, allow_nil?: false, public?: true) + attribute(:response_value, AshPostgres.Test.Types.Response, allow_nil?: false, public?: true) + attribute(:nullable_string_value, :string, public?: true) + end + + actions do + defaults([:read]) + + create :create do + accept(:*) + end + + update :update_one do + argument(:integer_value, :integer, allow_nil?: false) + change(atomic_update(:integer_value, expr(^arg(:integer_value)))) + validate(AshPostgres.Test.ImmutableErrorTester.Validations.UpdateOne) + end + + update :update_many do + accept(:*) + validate(AshPostgres.Test.ImmutableErrorTester.Validations.UpdateMany) + end + + update :update_literal do + validate(AshPostgres.Test.ImmutableErrorTester.Validations.UpdateLiteral) + end + end +end diff --git a/test/support/resources/immutable_error_tester/struct.ex b/test/support/resources/immutable_error_tester/struct.ex new file mode 100644 index 00000000..2b24494f --- /dev/null +++ b/test/support/resources/immutable_error_tester/struct.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ImmutableErrorTester.Struct do + @moduledoc false + use Ash.TypedStruct + + typed_struct do + field(:name, :string, allow_nil?: false) + field(:count, :integer, allow_nil?: false) + field(:active?, :boolean, allow_nil?: false) + end +end diff --git a/test/support/resources/immutable_error_tester/validations/update_literal.ex b/test/support/resources/immutable_error_tester/validations/update_literal.ex new file mode 100644 index 00000000..453ec37e --- /dev/null +++ b/test/support/resources/immutable_error_tester/validations/update_literal.ex @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ImmutableErrorTester.Validations.UpdateLiteral do + @moduledoc false + use Ash.Resource.Validation + + import Ash.Expr + + @impl true + def init(opts), do: {:ok, opts} + + # Validation that always fails. Builds an error with only literal values, zero expression values. + # + # Use fragment with PG function to ensure the validation runs as part of the query. + @impl true + def atomic(_changeset, _opts, _context) do + [ + {:atomic, :*, expr(fragment("pg_column_size(?) != 0", ^ref(:id))), + expr( + error(AshPostgres.Test.ImmutableErrorTester.Error, + string_value: "literal string", + integer_value: 123, + float_value: 9.99, + boolean_value: false, + string_array_value: ["alpha", "beta"], + nullable_string_value: nil + ) + )} + ] + end +end diff --git a/test/support/resources/immutable_error_tester/validations/update_many.ex b/test/support/resources/immutable_error_tester/validations/update_many.ex new file mode 100644 index 00000000..e41959dc --- /dev/null +++ b/test/support/resources/immutable_error_tester/validations/update_many.ex @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ImmutableErrorTester.Validations.UpdateMany do + @moduledoc false + use Ash.Resource.Validation + + import Ash.Expr + + @impl true + def init(opts), do: {:ok, opts} + + # Validation that always fails. Builds an error that include many (all attributes) value + # expressions, and zero literal values (empty base input). + # + # Use fragment with PG function to ensure the validation runs as part of the query. + @impl true + def atomic(_changeset, _opts, _context) do + [ + {:atomic, :*, expr(fragment("pg_column_size(?) != 0", ^ref(:id))), + expr( + error( + AshPostgres.Test.ImmutableErrorTester.Error, + atom_value: ^atomic_ref(:atom_value), + string_value: ^atomic_ref(:string_value), + integer_value: ^atomic_ref(:integer_value), + float_value: ^atomic_ref(:float_value), + boolean_value: ^atomic_ref(:boolean_value), + struct_value: ^atomic_ref(:struct_value), + uuid_value: ^atomic_ref(:uuid_value), + date_value: ^atomic_ref(:date_value), + time_value: ^atomic_ref(:time_value), + ci_string_value: ^atomic_ref(:ci_string_value), + naive_datetime_value: ^atomic_ref(:naive_datetime_value), + utc_datetime_value: ^atomic_ref(:utc_datetime_value), + timestamptz_value: ^atomic_ref(:timestamptz_value), + string_array_value: ^atomic_ref(:string_array_value), + response_value: ^atomic_ref(:response_value), + nullable_string_value: ^atomic_ref(:nullable_string_value) + ) + )} + ] + end +end diff --git a/test/support/resources/immutable_error_tester/validations/update_one.ex b/test/support/resources/immutable_error_tester/validations/update_one.ex new file mode 100644 index 00000000..a7351e56 --- /dev/null +++ b/test/support/resources/immutable_error_tester/validations/update_one.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ImmutableErrorTester.Validations.UpdateOne do + @moduledoc false + use Ash.Resource.Validation + + import Ash.Expr + + @impl true + def init(opts), do: {:ok, opts} + + # Validation that always fails. Builds an error with a single expression value and literal + # values (non-empty base input). + # + # Use fragment with PG function to ensure the validation runs as part of the query. + @impl true + def atomic(_changeset, _opts, _context) do + [ + {:atomic, [:integer_value, :id], expr(fragment("pg_column_size(?) != 0", ^ref(:id))), + expr( + error( + Ash.Error.Changes.InvalidAttribute, + field: :integer_value, + value: ^atomic_ref(:integer_value), + message: "integer_value failed validation" + ) + )} + ] + end +end diff --git a/test/support/test_repo.ex b/test/support/test_repo.ex index cf45c646..b21721e8 100644 --- a/test/support/test_repo.ex +++ b/test/support/test_repo.ex @@ -16,7 +16,15 @@ defmodule AshPostgres.TestRepo do def prefer_transaction_for_atomic_updates?, do: false def installed_extensions do - ["ash-functions", "uuid-ossp", "pg_trgm", "citext", AshPostgres.TestCustomExtension, "ltree"] -- + [ + "ash-functions", + "uuid-ossp", + "pg_trgm", + "citext", + AshPostgres.TestCustomExtension, + AshPostgres.Extensions.ImmutableRaiseError, + "ltree" + ] -- Application.get_env(:ash_postgres, :no_extensions, []) end @@ -40,4 +48,8 @@ defmodule AshPostgres.TestRepo do |> Ash.read!() |> Enum.map(&"org_#{&1.id}") end + + def immutable_expr_error? do + Application.get_env(:ash_postgres, :test_repo_use_immutable_errors?, false) + end end