halostatue

halostatue

If I am using cast_embed/3, how do I clear an embeds_many entry?

I have a table flows that has a options JSONB column

CREATE TABLE flows (
  id bigint generated always as identity primary key,
  options JSONB
);

I have defined the schema as follows:

defmodule Test.Flow do
  use Ecto.Schema
  import Ecto.Changeset

  defmodule Option do
    use Ecto.Schema
    import Ecto.Changeset

    @primary_key false
    embedded_schema do
      field :value, :string
    end

    def changeset(opts \\ %__MODULE__{}, attrs) do
      cast(opts, attrs, [:value])
    end
  end

  schema "flows" do
    embeds_many :options, Option, on_replace: :delete
  end

  def changeset(flows \\ %__MODULE__{}, attrs) do
    flows
    |> cast(attrs, [:id])
    |> cast_embed(:options)
    |> validate_length(:options, min: 1)
  end
end

In Typescript type terms, the type would look something like this:

interface Flow {
  id: number
  options?: [Option, ...Option[]] | null
}

interface Option {
  value: string
}

That is, options should be null or an array of at least one Option. But I can’t make that happen:

iex(1)> {:ok, f} = Repo.insert(Test.Flow.changeset(%{})
{:ok,
 %Test.Flow{
   __meta__: #Ecto.Schema.Metadata<:loaded, "flows">,
   id: 1,
   options: []
 }}
iex(2)> Test.Flow.changeset(f, %{options: nil})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [options: {"is invalid", [validation: :embed, type: {:array, :map}]}],
  data: #Test.Flow<>,
  valid?: false
>
iex(3)> Repo.update!(f, %{options: [%{}]})
%Test.Flow{
  __meta__: #Ecto.Schema.Metadata<:loaded, "flows">,
  id: 1,
  options: [%Test.Flow.Option{value: nil}]
}
iex(4)> f = Repo.get(TestFlow, 1)
%Test.Flow{
  __meta__: #Ecto.Schema.Metadata<:loaded, "flows">,
  id: 1,
  options: [%Test.Flow.Option{value: nil}]
}
iex(5)> Test.Flow.changeset(f, %{options: []})
#Ecto.Changeset<
  action: nil,
  changes: %{
    options: [
      #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
       data: #Test.Flow.Option<>, valid?: true>
    ]
  },
  errors: [
    options: {"should have at least %{count} item(s)",
     [count: 1, validation: :length, kind: :min, type: :list]}
  ],
  data: #Test.Flow<>,
  valid?: false
>

This feels like it should be possible, if not easy, but I don’t really see a way to do it, especially since there doesn’t appear to be a distinction between attrs of %{} (options is missing) and %{options: nil} (options is explicitly nulled). That is, when I do Test.Flow.changeset(f, %{options: nil}).changes, I get %{}.

I suppose that I could use a sigil value (:none), but that feels…awkward.

Showing Posts 1 to 8

dimitarvp

dimitarvp

I seem to remember people around here saying that Ecto.Changeset is not treating nil as an actual change but don’t quote me on that.

But in case it’s true, what’s the difficulty in making the empty list the special empty value you’re looking for?

halostatue

halostatue OP

Because it doesn’t seem to work. I have added the following to my .iex.exs for my project to explore.

defmodule Test.Flow do
  use Ecto.Schema
  import Ecto.Changeset

  defmodule Option do
    use Ecto.Schema
    import Ecto.Changeset

    @primary_key false
    embedded_schema do
      field :value, :string
    end

    def changeset(opts \\ %__MODULE__{}, attrs) do
      cast(opts, attrs, [:value])
    end
  end

  schema "flows" do
    embeds_many :options, Option, on_replace: :delete
  end

  def changeset(flows \\ %__MODULE__{}, attrs) do
    flows
    |> cast(attrs, [:id])
    |> cast_embed(:options)
    |> tap(fn changeset ->
      IO.inspect(fetch_change(changeset, :options))
    end)
    |> dbg()
  end
end

And this is my experimental session (I just dropped and recreated the table in psql):

iex(1)> Repo.insert!(Test.Flow.changeset(%{options: [%{}]}))
{:ok,
 [
   #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
    data: #Test.Flow.Option<>, valid?: true>
 ]}
[.iex.exs:156: Test.Flow.changeset/2]
flows #=> %Test.Flow{
  __meta__: #Ecto.Schema.Metadata<:built, "flows">,
  id: nil,
  options: []
}
|> cast(attrs, [:id]) #=> #Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Test.Flow<>,
 valid?: true>
|> cast_embed(:options) #=> #Ecto.Changeset<
  action: nil,
  changes: %{
    options: [
      #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
       data: #Test.Flow.Option<>, valid?: true>
    ]
  },
  errors: [],
  data: #Test.Flow<>,
  valid?: true
>
|> tap(fn changeset -> IO.inspect(fetch_change(changeset, :options)) end) #=> #Ecto.Changeset<
  action: nil,
  changes: %{
    options: [
      #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
       data: #Test.Flow.Option<>, valid?: true>
    ]
  },
  errors: [],
  data: #Test.Flow<>,
  valid?: true
>

%Test.Flow{
  __meta__: #Ecto.Schema.Metadata<:loaded, "flows">,
  id: 1,
  options: [%Test.Flow.Option{value: nil}]
}
iex(2)> Test.Flow.changeset(Repo.get(Test.Flow, 1), %{options: []})
iex(2)> Test.Flow.changeset(Repo.get(Test.Flow, 1), %{options: []})
{:ok,
 [
   #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
    data: #Test.Flow.Option<>, valid?: true>
 ]}
[.iex.exs:156: Test.Flow.changeset/2]
flows #=> %Test.Flow{
  __meta__: #Ecto.Schema.Metadata<:loaded, "flows">,
  id: 1,
  options: [%Test.Flow.Option{value: nil}]
}
|> cast(attrs, [:id]) #=> #Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Test.Flow<>,
 valid?: true>
|> cast_embed(:options) #=> #Ecto.Changeset<
  action: nil,
  changes: %{
    options: [
      #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
       data: #Test.Flow.Option<>, valid?: true>
    ]
  },
  errors: [],
  data: #Test.Flow<>,
  valid?: true
>
|> tap(fn changeset -> IO.inspect(fetch_change(changeset, :options)) end) #=> #Ecto.Changeset<
  action: nil,
  changes: %{
    options: [
      #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
       data: #Test.Flow.Option<>, valid?: true>
    ]
  },
  errors: [],
  data: #Test.Flow<>,
  valid?: true
>

#Ecto.Changeset<
  action: nil,
  changes: %{
    options: [
      #Ecto.Changeset<action: :replace, changes: %{}, errors: [],
       data: #Test.Flow.Option<>, valid?: true>
    ]
  },
  errors: [],
  data: #Test.Flow<>,
  valid?: true
>
iex(3)> Repo.update!(v())
{:ok,
 %Test.Flow{
   __meta__: #Ecto.Schema.Metadata<:loaded, "flows">,
   id: 1,
   options: []
 }}

But that is incorrect. If the array is empty, the field should be set to NULL, and that’s clearly not the case:

psql> select * from flows;
┌────┬─────────┐
│ id │ options │
├────┼─────────┤
│  1 │ []      │
└────┴─────────┘
(1 row)

I can change the wire serialization so that an empty array is returned as null, but it feels odd that a nullable field can no longer be set to null — and I can’t see any way from the fetch_change to know that this is going to be cleared. I would have to refer back to attrs to see if I should do anything.

sodapopcan

sodapopcan

This should be:

Repo.insert!(Test.Flow.changeset(%{options: []}))

IE, don’t include the empty map as that implies you have an option you want to run through a changeset. You just want an empty list meaning “no options.” Otherwise ya, AFAIK, Ecto’s has_many relations are always going to be lists (ie, not null). There was some discussion around this that I can’t find atm.

halostatue

halostatue OP

That initial insert was to provide a record that needed to have [{value: null}] turned into null through clearing to illustrate my problem. The later changeset calls illustrate this more clearly.

My issue is with embeds_many, not has_many. If I were storing this data in a separate table, this would not be a problem, because the “owned” records would simply be deleted.

This feels like something that can and should be handled better. Maybe with something like embeds_many :field, Type, nullable: true, which would theoretically be able to flag to cast_embed/4 that if the value is nil (and not missing), then it should be set to nil in the update.

I’m going to need to figure a way around this, because an empty list is explicitly not a valid value. It’s either nil or a non-empty list.

sodapopcan

sodapopcan

Sorry, I meant embeds_many—they work the same in that regard. And also I seem to have misread your iex output.

I found the discussion I was thinking of:

halostatue

halostatue OP

Thanks for the link.

Yeah. I have explicitly disallowed the empty list in the type definition, which is why this behaviour is…disappointing. I may need to see if there is a way to make embeds_many and cast_embed work properly for this case, because as a few posts in that thread make clear, NULL is not the same as [] and we’re just somewhat lucky that I hadn’t made a constraint on the table which checked for null or jsonb_array_length(options) > 1 (this is still a good idea, but until I can work around this issue, I can’t have it).

sodapopcan

sodapopcan

You could add your voice to that thread. Not sure if it will do much good but you can always try! My guess is that this just doesn’t come up often enough and there are other ways around it, though I’m not quite sure of the workarounds myself.

halostatue

halostatue OP

One workaround is to define an Ecto.Type. My example here isn’t well organized (the type here should be a different module, OptionList), but that’s fine for an example:

defmodule Test.Flow do
  use Ecto.Schema
  import Ecto.Changeset

  defmodule Option do
    use Ecto.Schema
    use Ecto.Type

    import Ecto.Changeset

    @primary_key false
    embedded_schema do
      field :value, :string
    end

    def changeset(opts \\ %__MODULE__{}, attrs) do
      cast(opts, attrs, [:value])
    end

    @impl Ecto.Type
    def type, do: {:array, :map}

    @impl Ecto.Type
    def cast([_ | _] = list) do
      cond do
        Enum.all?(list, &is_struct(&1, __MODULE__)) -> {:ok, list}
        Enum.all?(list, &is_map/1) -> load(list)
        true -> :error
      end
    end

    def cast(nil), do: {:ok, nil}
    def cast(_), do: :error

    @impl Ecto.Type
    def load(nil), do: {:ok, nil}

    def load(data) when is_list(data) do
      {
        :ok,
        Enum.map(data, fn entry ->
          struct!(
            __MODULE__,
            for {k, v} <- entry do
              {String.to_existing_atom(k), v}
            end
          )
        end)
      }
    end

    @impl Ecto.Type
    def dump([_ | _] = list) do
      if Enum.all?(list, &match?(%__MODULE__{}, &1)) do
        {:ok, Enum.map(list, &Map.from_struct/1)}
      else
        :error
      end
    end

    def dump(nil), do: {:ok, nil}
    def dump(_), do: :error
  end

  schema "flows" do
    field :options, Option
  end

  def changeset(flows \\ %__MODULE__{}, attrs) do
    cast(flows, attrs, [:id, :options])
  end
end

It’s a lot of work, and I suspect that some of it could be wrapped in a macro.

I fear that the lesson here is to avoid embeds_many if your column is intentionally nullable, because Ecto works against your design in this case.

— All posts loaded —

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kszambelanczyk
Hello! Could someone please give me a help/sample code, how to delete a file from s3 using waffle/waffle_ecto from Phoenix app. I creat...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews