wbotelhos/normalizy

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# Normalizy

[![CI](https://github.com/wbotelhos/normalizy/workflows/CI/badge.svg)](https://github.com/wbotelhos/normalizy/actions)
[![Gem Version](https://badge.fury.io/rb/normalizy.svg)](https://badge.fury.io/rb/normalizy)
[![Maintainability](https://api.codeclimate.com/v1/badges/3896d0a11bee012c818c/maintainability)](https://codeclimate.com/github/wbotelhos/normalizy/maintainability)
[![codecov](https://codecov.io/gh/wbotelhos/normalizy/branch/master/graph/badge.svg?token=0XTRFDFHDq)](https://codecov.io/gh/wbotelhos/normalizy)
[![Sponsor](https://img.shields.io/badge/sponsor-%3C3-green)](https://www.patreon.com/wbotelhos)

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@example.com  ` valid like `email@example.com` with no need to override acessors methods.

## install

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

```ruby
gem 'normalizy'
```

So generates an initializer for future custom configurations:

```ruby
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:

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

Now some email like `MyEmail@Example.com` will be saved as `myemail@example.com`.

## Filters

We have a couple of built-in filters.

### Date

Transform a value to date format.

```ruby
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:

```ruby
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:

```ruby
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:

```yml
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:

```ruby
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:

```ruby
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.

```ruby
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:

```ruby
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:

```yaml
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`:

```ruby
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:

```ruby
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`:

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

'$ 42.0'
# 42.00
```

```ruby
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:

```yaml
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:

```ruby
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:

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

'$ 42.00'
# 4200.0
```

### Number

Transform text to valid number.

```ruby
normalizy :age, with: :number

' 32x'
# '32'
```

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

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

' 32x'
# 32
```

### Percent

Transform a value to a valid percent format.

```ruby
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:

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

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

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

```yaml
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`:

```ruby
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:

```ruby
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`:

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

'42.0 %'
# 42.00
```

```ruby
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:

```yaml
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:

```ruby
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:

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

'42.00 %'
# 4200.0
```

### Slug

Convert texto to slug.

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

#### to

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

```ruby
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`

```ruby
normalizy :name, with: :strip
'  Washington  Botelho  '
# 'Washington  Botelho'
```

```ruby
normalizy :name, with: { strip: { side: :left } }
'  Washington  Botelho  '
# 'Washington  Botelho  '
```

```ruby
normalizy :name, with: { strip: { side: :right } }
'  Washington  Botelho  '
# '  Washington  Botelho'
```

```ruby
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.

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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](#multiple-filters)

## Default Filters

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

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

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

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

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

```ruby
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`:

```ruby
module Normalizy
  module Filters
    module Blacklist
      def self.call(input)
        input.gsub 'Fuck', replacement: '***'
      end
    end
  end
end
```

```ruby
Normalizy.configure do |config|
  config.add :blacklist, Normalizy::Filters::Blacklist
end
```

Now you can use your custom filter:

```ruby
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:

```ruby
module Normalizy
  module Filters
    module Blacklist
      def self.call(input, options: {})
        input.gsub 'Fuck', replacement: options[:replacement]
      end
    end
  end
end
```

```ruby
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:

```ruby
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
```

```ruby
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.

```ruby
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:

```ruby
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.

```ruby
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:

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

-32
# 32
```

You can use it on filters declaration too:

```ruby
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:

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

Now, `age` will delegate to `number` filter.

And now, the aliased filter will work fine:

```ruby
normalizy :age, with: :age

'= 42'
# 42
```

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

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

Alias accepts options parameters too:

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

## RSpec

If you use [RSpec](http://rspec.info), we did built-in matchers for you.
Add the following code to your `rails_helper.rb`

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

And now you can use some of the matchers:

##### Result Matcher

```ruby
it { is_expected.to normalizy(:email).from(' Email@example.com  ').to 'email@example.com' }
```

##### Filter Matcher

It will match the given filter literally:

```ruby
it { is_expected.to normalizy(:email).with :downcase }
```

```ruby
it { is_expected.to normalizy(:email).with %i[downcase squish] }
```

```ruby
it { is_expected.to normalizy(:email).with(trim: { side: :left }) }
```