diff --git a/lib/extensions/immutable_raise_error.ex b/lib/extensions/immutable_raise_error.ex index 7cda113d..b7ccf347 100644 --- a/lib/extensions/immutable_raise_error.ex +++ b/lib/extensions/immutable_raise_error.ex @@ -28,6 +28,8 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do use AshPostgres.CustomExtension, name: "immutable_raise_error", latest_version: 1 + require Ecto.Query + @impl true def install(0) do ash_raise_error_immutable() @@ -71,4 +73,157 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do \"\"\") """ end + + @doc false + def immutable_error_expr( + query, + %Ash.Query.Function.Error{arguments: [exception, input]} = value, + bindings, + embedded?, + acc, + type + ) do + acc = %{acc | has_error?: true} + + {encoded, 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 + ) + else + {Jason.encode!(%{exception: inspect(exception), input: Map.new(input)}), acc} + end + + dynamic_type = + if type do + # This is a type hint, if we're raising an error, we tell it what the value + # type *would* be in this expression so that we can return a "NULL" of that type + # its weird, but there isn't any other way that I can tell :) + AshSql.Expr.validate_type!(query, type, value) + + type = + AshSql.Expr.parameterized_type( + bindings.sql_behaviour, + type, + [], + :expr + ) + + Ecto.Query.dynamic(type(fragment("NULL"), ^type)) + else + nil + end + + case {dynamic_type, immutable_error_expr_token(query, bindings)} do + {_, nil} -> + :error + + {nil, row_token} -> + {:ok, + Ecto.Query.dynamic( + fragment("ash_raise_error_immutable(?::jsonb, ?)", ^encoded, ^row_token) + ), acc} + + {dynamic_type, row_token} -> + {:ok, + Ecto.Query.dynamic( + fragment( + "ash_raise_error_immutable(?::jsonb, ?, ?)", + ^encoded, + ^dynamic_type, + ^row_token + ) + ), 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 + ref_binding = bindings.root_binding + + pk_attr_names = Ash.Resource.Info.primary_key(resource) + + attr_names = + case pk_attr_names do + [] -> + case Ash.Resource.Info.attributes(resource) do + [%{name: name} | _] -> [name] + _ -> [] + end + + pk -> + pk + end + + if ref_binding && attr_names != [] do + value_exprs = + Enum.map(attr_names, fn attr_name -> + if bindings[:parent?] && + ref_binding not in List.wrap(bindings[:lateral_join_bindings]) do + Ecto.Query.dynamic(field(parent_as(^ref_binding), ^attr_name)) + else + Ecto.Query.dynamic(field(as(^ref_binding), ^attr_name)) + end + end) + + row_parts = + value_exprs + |> Enum.map(&{:casted_expr, &1}) + |> Enum.intersperse({:raw, ", "}) + + {%Ecto.Query.DynamicExpr{} = token, _acc} = + AshSql.Expr.dynamic_expr( + query, + %Ash.Query.Function.Fragment{ + embedded?: false, + arguments: [raw: "ROW("] ++ row_parts ++ [raw: ")"] + }, + AshSql.Expr.set_location(bindings, :sub_expr), + false + ) + + token + else + nil + end + end end diff --git a/lib/sql_implementation.ex b/lib/sql_implementation.ex index fefdf876..71b7f03d 100644 --- a/lib/sql_implementation.ex +++ b/lib/sql_implementation.ex @@ -200,6 +200,31 @@ defmodule AshPostgres.SqlImplementation do end end + def expr( + query, + %Ash.Query.Function.Error{} = value, + bindings, + embedded?, + acc, + type + ) do + resource = query.__ash_bindings__.resource + repo = AshSql.dynamic_repo(resource, AshPostgres.SqlImplementation, query) + + if repo.immutable_expr_error?() do + AshPostgres.Extensions.ImmutableRaiseError.immutable_error_expr( + query, + value, + bindings, + embedded?, + acc, + type + ) + else + :error + end + end + def expr( _query, _expr, @@ -334,9 +359,4 @@ defmodule AshPostgres.SqlImplementation do {types, new_returns || returns} end - - @impl true - def immutable_errors?(repo) do - repo.immutable_expr_error?() - end end diff --git a/mix.exs b/mix.exs index cdc61840..79848b88 100644 --- a/mix.exs +++ b/mix.exs @@ -168,7 +168,6 @@ defmodule AshPostgres.MixProject do [ {:ash, ash_version("~> 3.5 and >= 3.5.35")}, {:spark, "~> 2.3 and >= 2.3.4"}, - # TODO: bump to next ash_sql release {:ash_sql, ash_sql_version(git: "https://github.com/ash-project/ash_sql.git")}, {:igniter, "~> 0.6 and >= 0.6.14", optional: true}, {:ecto_sql, "~> 3.13"}, diff --git a/mix.lock b/mix.lock index 7cd067b4..f5d12abd 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ "ash": {:hex, :ash, "3.5.43", "222f9a8ac26ad3b029f8e69306cc83091c992d858b4538af12e33a148f301cab", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "48b2aa274c524f5b968c563dd56aec8f9b278c529c8aa46e6fe0ca564c26cc1c"}, - "ash_sql": {:git, "https://github.com/ash-project/ash_sql.git", "65854408e7ce129f78fabafb0a4393f0142da6a6", []}, + "ash_sql": {:git, "https://github.com/ash-project/ash_sql.git", "3044c0555dbe6733d16868951ee89e6d5ef336fa", []}, "benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},