Search code examples
rubydry-rbdry-typesdry-struct

dry-struct How to conditionally validate one attribute?


I'm using dry-types and dry-struct and I would like to have a conditional validation.

for the class:

class Tax < Dry::Struct
  attribute :tax_type, Types::String.constrained(min_size: 2, max_size: 3, included_in: %w[IVA IS NS])
  attribute :tax_country_region, Types::String.constrained(max_size: 5)
  attribute :tax_code, Types::String.constrained(max_size: 10)
  attribute :description, Types::String.constrained(max_size: 255)
  attribute :tax_percentage, Types::Integer
  attribute :tax_ammount, Types::Integer.optional
end

I want to validate tax_ammount as an Integer and mandatory if `tax_type == 'IS'.


Solution

  • dry-struct is really for basic type assertion and coercion.

    If you want more complex validation then you probably want to implement dry-validation as well (as recommended by dry-rb)

    See Validating data with dry-struct which states

    Please don’t. Structs are meant to work with valid input, it cannot generate error messages good enough for displaying them for a user etc. Use dry-validation for validating incoming data and then pass its output to structs.

    The conditional validation using dry-validation would be something like

    TaxValidation = Dry::Validation.Schema do
    
      # Could be:
      #   required(:tax_type).filled(:str?, 
      #      size?: 2..3, 
      #      included_in?: %w(IVA IS NS)) 
      # but since we are validating against a list of Strings I figured the rest was implied
      required(:tax_type).filled(included_in?: %w(IVA IS NS)) 
      optional(:tax_amount).maybe(:int?)
    
      # rule name is of your choosing and will be used 
      # as the errors key (i just chose `tax_amount` for consistency)
      rule(tax_amount:[:tax_type, :tax_amount]) do |tax_type, tax_amount|
        tax_type.eql?('IS').then(tax_amount.filled?) 
      end
    end
    
    • This requires tax_type to be in the %w(IVA IS NS) list;
    • Allows tax_amount to be optional but if it is filled in it must be an Integer (int?) and;
    • If tax_type == 'IS' (eql?('IS')) then tax_amount must be filled in (which means it must be an Integer based on the rule above).

    Obviously you can validate your other inputs as well but I left these out for the sake of brevity.

    Examples:

    TaxValidation.({}).success?
    #=> false
    TaxValidation.({}).errors
    # => {:tax_type=>["is missing"]}
    TaxValidation.({tax_type: 'NO'}).errors
    #=>  {:tax_type=>["must be one of: IVA, IS, NS"]}
    TaxValidation.({tax_type: 'NS'}).errors
    #=>  {}
    TaxValidation.({tax_type: 'IS'}).errors
    #=> {:tax_amount=>["must be filled"]}
    TaxValidation.({tax_type: 'IS',tax_amount:'NO'}).errors
    #=> {:tax_amount=>["must be an integer"]}
    TaxValidation.({tax_type: 'NS',tax_amount:12}).errors 
    #=> {}
    TaxValidation.({tax_type: 'NS',tax_amount:12}).success?
    #=> true