Skip to content

🔧 Attribute normalizer for Rails

License

Notifications You must be signed in to change notification settings

wbotelhos/normalizy

Repository files navigation

Normalizy

CI Gem Version Maintainability codecov Sponsor

Attribute normalizer for Rails.

Description

If you know the obvious format of an input, why not normalize it instead of raise an validation error to your use? Make the follow email [email protected] valid like [email protected] with no need to override acessors methods.

install

Add the following code on your Gemfile and run bundle install:

gem 'normalizy'

So generates an initializer for future custom configurations:

rails g normalizy:install

It will generates a file config/initializers/normalizy.rb where you can configure you own normalizer and choose some defaults one.

Usage

On your model, just add normalizy callback with the attribute you want to normalize and the filter to be used:

class User < ApplicationRecord
  normalizy :name, with: :downcase
end

Now some email like [email protected] will be saved as [email protected].

Filters

We have a couple of built-in filters.

Date

Transform a value to date format.

normalizy :birthday, with: :date

'1984-10-23'
# Tue, 23 Oct 1984 00:00:00 UTC +00:00

By default, the date is treat as %F format and as UTC time.

format

You can change the format using the format options:

normalizy :birthday, with: { date: { format: '%y/%m/%d' } }

'84/10/23'
# Tue, 23 Oct 1984 00:00:00 UTC +00:00

time zone

To convert the date on your time zone, just provide the time_zone option:

normalizy :birthday, with: { date: { time_zone: Time.zone } }

'1984-10-23'
# Tue, 23 Oct 1984 00:00:00 EDT -04:00

error message

If an invalid date is provided, Normalizy will add an error on attribute of the related object. You can customize the error via I18n config:

en:
  normalizy:
    errors:
      date:
        user:
          birthday: '%{value} is an invalid date.'

If no configuration is provided, the default message will be '%{value} is an invalid date..

adjust

If your model receive a Time or DateTime, you can provide adjust options to change you time to begin o the day:

normalizy :birthday, with: { date: { adjust: :begin } }

Tue, 23 Oct 1984 11:30:00 EDT -04:00
# Tue, 23 Oct 1984 00:00:00 EDT -04:00

Or to the end of the day:

normalizy :birthday, with: { date: { adjust: :end } }

Tue, 23 Oct 1984 00:00:00 EDT -04:00
# Tue, 23 Oct 1984 11:59:59 EDT -04:00

Money

Transform a value to money format.

normalizy :amount, with: :money

'$ 42.00'
# '42.00'

separator

The separator will be keeped on value to be possible cast the right integer value. You can change this separator:

normalizy :amount, with: { money: { separator: ',' } }

'R$ 42,00'
# '42,00'

If you do not want pass it as options, Normalizy will fetch your I18n config:

en:
  number:
    currency:
      format:
        separator: '.'

And if it does not exists, . will be used as default.

type

You can retrieve the value in cents format, use the type options as cents:

normalizy :amount, with: { money: { type: :cents } }

'$ 42.00'
# '4200'

precision

As you could see on the last example, when using type: :cents is important the number of decimal digits. So, you can configure it to avoid the following error:

normalizy :amount, with: { money: { type: :cents } }

'$ 42.0'
# 420

When you parse it back, the value need to be divided by 100 to be presented, but it will result in a value you do not want: 4.2 instead of the original 42.0. Just provide a precision:

normalizy :amount, with: { money: { precision: 2 } }

'$ 42.0'
# 42.00
normalizy :amount, with: { money: { precision: 2, type: :cents } }

'$ 42.0'
# 4200

If you do not want pass it as options, Normalizy will fetch your I18n config:

en:
  number:
    currency:
      format:
        precision: 2

And if it does not exists, 2 will be used as default.

cast

If you need get a number over a normalized string in a number style, provide cast option with desired cast method:

normalizy :amount, with: { money: { cast: :to_i } }

'$ 42.00'
# 4200

Just pay attention to avoid to use type: :cents together cast with float parses. Since type runs first, you will add decimal in a number that already is represented with decimal, but as integer:

normalizy :amount, with: { money: { cast: :to_f, type: :cents } }

'$ 42.00'
# 4200.0

Number

Transform text to valid number.

normalizy :age, with: :number

' 32x'
# '32'

If you want cast the value, provide cast option with desired cast method:

normalizy :age, with: { number: { cast: :to_i } }

' 32x'
# 32

Percent

Transform a value to a valid percent format.

normalizy :amount, with: :percent

'42.00 %'
# '42.00'

separator

The separator will be keeped on value to be possible cast the right integer value. You can change this separator:

normalizy :amount, with: { percent: { separator: ',' } }

'42,00 %'
# '42,00'

If you do not want pass it as options, Normalizy will fetch your I18n config:

en:
  number:
    percentage:
      format:
        separator: '.'

And if it does not exists, . will be used as default.

type

You can retrieve the value in integer format, use the type options as integer:

normalizy :amount, with: { percent: { type: :integer } }

'42.00 %'
# '4200'

precision

As you could see on the last example, when using type: :integer is important the number of decimal digits. So, you can configure it to avoid the following error:

normalizy :amount, with: { percent: { type: :integer } }

'42.0 %'
# 420

When you parse it back, the value need to be divided by 100 to be presented, but it will result in a value you do not want: 4.2 instead of the original 42.0. Just provide a precision:

normalizy :amount, with: { percent: { precision: 2 } }

'42.0 %'
# 42.00
normalizy :amount, with: { percent: { precision: 2, type: :integer } }

'42.0 %'
# 4200

If you do not want pass it as options, Normalizy will fetch your I18n config:

en:
  number:
    percentage:
      format:
        separator: 2

And if it does not exists, 2 will be used as default.

cast

If you need get a number over a normalized string in a number style, provide cast option with desired cast method:

normalizy :amount, with: { percent: { cast: :to_i } }

'42.00 %'
# 4200

Just pay attention to avoid to use type: :integer together cast with float parses. Since type runs first, you will add decimal in a number that already is represented with decimal, but as integer:

normalizy :amount, with: { percent: { cast: :to_f, type: :integer } }

'42.00 %'
# 4200.0

Slug

Convert texto to slug.

normalizy :slug, with: :slug
'Washington é Botelho'
# 'washington-e-botelho'

to

You can slug a field based on other just sending the result value.

normalizy :title, with: { slug: { to: :slug } }

model.title = 'Washington é Botelho'

model.slug
# 'washington-e-botelho'

Strip

Cleans edge spaces.

Options:

  • side: :left, :right or :both. Default: :both
normalizy :name, with: :strip
'  Washington  Botelho  '
# 'Washington  Botelho'
normalizy :name, with: { strip: { side: :left } }
'  Washington  Botelho  '
# 'Washington  Botelho  '
normalizy :name, with: { strip: { side: :right } }
'  Washington  Botelho  '
# '  Washington  Botelho'
normalizy :name, with: { strip: { side: :both } }
'  Washington  Botelho  '
# 'Washington  Botelho'

As you can see, the rules can be passed as Symbol/String or as Hash if it has options.

Truncate

Remove excedent string part from a gived limit.

normalizy :description, with: { truncate: { limit: 10 } }

'Once upon a time in a world far far away'
# 'Once upon '

Multiple Filters

You can normalize with a couple of filters at once:

normalizy :name, with: { %i[squish titleize] }
'  washington  botelho  '
# 'Washington Botelho'

Multiple Attributes

You can normalize more than one attribute at once too, with one or multiple filters:

normalizy :email, :username, with: :downcase

Of course you can declare multiple attribute and multiple filters, either. It is possible to make sequential normalizy calls, but take care! Since we use prepend module the last line will run first then others:

normalizy :username, with: :downcase
normalizy :username, with: :titleize

'BoteLho'
# 'bote lho'

As you can see, titleize runs first then downcase. Each line will be evaluated from the bottom to the top. If it is hard to you accept, use Muiltiple Filters

Default Filters

You can configure some default filters to be runned. Edit initializer at config/initializers/normalizy.rb:

Normalizy.configure do |config|
  config.default_filters = [:squish]
end

Now, all normalization will include squish, even when no rule is declared.

normalizy :name
"  Washington  \n  Botelho  "
# 'Washington Botelho'

If you declare some filter, the default filter squish will be runned together:

normalizy :name, with: :downcase
'  washington  botelho  '
# 'Washington Botelho'

Custom Filter

You can create a custom filter that implements call method with an input as argument and an optional options:

module Normalizy
  module Filters
    module Blacklist
      def self.call(input)
        input.gsub 'Fuck', replacement: '***'
      end
    end
  end
end
Normalizy.configure do |config|
  config.add :blacklist, Normalizy::Filters::Blacklist
end

Now you can use your custom filter:

normalizy :name, with: :blacklist

'Washington Fuck Botelho'
# 'Washington *** Botelho'

options

If you want to pass options to your filter, just call it as a hash and the value will be send to the custom filter:

module Normalizy
  module Filters
    module Blacklist
      def self.call(input, options: {})
        input.gsub 'Fuck', replacement: options[:replacement]
      end
    end
  end
end
normalizy :name, with: { blacklist: { replacement: '---' } }

'Washington Fuck Botelho'
# 'Washington --- Botelho'

options value

By default, Modules and instance methods of class will receveis the following attributes on options argument:

  • object: The object that Normalizy is acting;
  • attribute: The attribute of the object that Normalizy is acting.

You can pass a block and it will be received on filter:

module Normalizy
  module Filters
    module Blacklist
      def self.call(input, options: {})
        value = input.gsub('Fuck', 'filtered')

        value = yield(value) if block_given?

        value
      end
    end
  end
end
normalizy :name, with: { :blacklist, &->(value) { value.sub('filtered', '(filtered 2x)') } }

'Washington Fuck Botelho'
# 'Washington (filtered 2x) Botelho'

Method Filters

If a built-in filter is not found, Normalizy will try to find a method in the current class.

normalizy :birthday, with: :parse_date

def parse_date(input)
  Time.zone.parse(input).strftime '%Y/%m/%d'
end

'1984-10-23'
# '1984/10/23'

If you gives an option, it will be passed to the function:

normalizy :birthday, with: { parse_date: { format: '%Y/%m/%d' }

def parse_date(input, options = {})
  Time.zone.parse(input).strftime options[:format]
end

'1984-10-23'
# '1984/10/23'

Block methods works here either.

Native Filter

After the missing built-in and class method, the fallback will be the value of native methods.

normalizy :name, with: :reverse

'Washington Botelho'
# "ohletoB notgnihsaW"

Inline Filter

Maybe you want to declare an inline filter, in this case, just use a Lambda or Proc:

normalizy :age, with: ->(input) { input.to_i.abs }

-32
# 32

You can use it on filters declaration too:

Normalizy.configure do |config|
  config.add :age, ->(input) { input.to_i.abs }
end

Alias

Sometimes you want to give a better name to your filter, just to keep the things semantic. Duplicates the code, as you know, is not a good idea, so, create an alias:

Normalizy.configure do |config|
  config.alias :age, :number
end

Now, age will delegate to number filter.

And now, the aliased filter will work fine:

normalizy :age, with: :age

'= 42'
# 42

If you need to alias multiple filters, just provide an array of them:

Normalizy.configure do |config|
  config.alias :username, %i[squish downcase]
end

Alias accepts options parameters too:

Normalizy.configure do |config|
  config.alias :left_trim, trim: { side: :left }
end

RSpec

If you use RSpec, we did built-in matchers for you. Add the following code to your rails_helper.rb

RSpec.configure do |config|
 config.include Normalizy::RSpec
end

And now you can use some of the matchers:

Result Matcher
it { is_expected.to normalizy(:email).from(' [email protected]  ').to '[email protected]' }
Filter Matcher

It will match the given filter literally:

it { is_expected.to normalizy(:email).with :downcase }
it { is_expected.to normalizy(:email).with %i[downcase squish] }
it { is_expected.to normalizy(:email).with(trim: { side: :left }) }