elixirphoenix-frameworkectochangeset

Elixir changeset custom validation


I'm having a bit of trouble trying to implement a custom changeset validation. My schema is:

defenum(VersionStateEnum, ["draft", "active"])
schema "versions" do
  field :expires_at, :utc_datetime
  field :state, VersionStateEnum
end

The validation I'm trying to implement is: The expires_at can only be set if the state is draft (this should also be valid for updates, I should not be able to remove the expires_at if the state is still draft) I tried the following:

defp validate_expires_at(changeset) do                                                                                                                                                                           
    expires_at = get_change(changeset, :expires_at)                                                                                                                                                                
    cond do                                                                                                                                                                                                        
      get_change(changeset, :state) == :draft ->                                                                                                                                                                   
        case expires_at do                                                                                                                                                                                         
          nil -> add_error(changeset, :expires_at, "can't be blank when state is draft")                                                                                                                           
          _ -> changeset                                                                                                                                                                                           
        end                                                                                                                                                                                                        
      get_change(changeset, :state) == :active ->                                                                                                                                                                  
        case expires_at do                                                                                                                                                                                         
          nil -> changeset                                                                                                                                                                                         
          _ -> add_error(changeset, :expires_at, "cannot be set when state is not draft")                                                                                                                          
        end                                                                                                                                                                                                        
      true ->                                                                                                                                                                                                      
        changeset                                                                                                                                                                                                  
    end                                                                                                                                                                                                            
  end
end

But it doesn't really work as I can update the expires_at to nil even if the state is draft. Any help is appreciated.

Edit 1: My changeset:

@required_fields [                                                                                                                                                                                                                                                                                                                                                                                               
    :state                                                                                                                                                                                                                                                                                                                                                                                                         
  ]                                                                                                                                                                                                                
@optional_fields [:expires_at]
def changeset(model, params \\ nil) do                                                                                                                                                                           
  model                                                                                                                                                                                                          
  |> cast(params, @required_fields ++ @optional_fields)                                                                                                                                                          
  |> validate_required(@required_fields)                                                                                                                                                                         
  |> validate_expires_at()                                                                                                                                                                                       
end

Where it's being called:

def create_document(attrs \\ %{}) do                                                                                                                                                                             
  %Document{}                                                                                                                                                                                                    
  |> Document.changeset(attrs)                                                                                                                                                                                   
  |> Repo.insert()                                                                                                                                                                                               
end

Solution

  • If I understand your problem correctly, I think that to solve it you should probably consider the struct given to the changeset as well.

    Because, the way your code is, you're only checking for the state from the changes in the changeset, but if you try to update the expires_at alone, the changes in the changeset will not include the state that might already be set to "draft", and therefore, the cond block in your validate_expires_at function will always match true, because the value will be nil.

    One workaround could be to update the function like:

    defp validate_expires_at(changeset) do                                                                                                                                                                           
        state = get_field(changeset, :state)
        expires_at = get_change(changeset, :expires_at)   
        case state do
          :draft ->
            case expires_at do                                                                                                                                                                                         
              nil -> add_error(changeset, :expires_at, "can't be blank when state is draft")                                                                                                                           
              _ -> changeset
            end
          :active ->
            case expires_at do
              nil -> changeset
              _ -> add_error(changeset, :expires_at, "cannot be set when state is not draft")
            end
          _ -> changeset
        end                                                                                                                                                                                                   
      end
    end
    

    Using get_field instead of get_changewill try to get the field from the changes, but if it wasn't changed, it can be taken from the existing struct and the rest of your function should work normally

    Not sure how the atom/String handling works when inserting and retrieving from the DB. You might need to check if state could be a String when taken from the changeset's data