Skip to content

yykamei/strong_csv

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

strong_csv

strong_csv

Type checker for a CSV file inspired by strong_json.

Motivation

Some applications have a feature to receive a CSV file uploaded by a user, and in general, it needs to validate each cell of the CSV file.

How should applications validate them? Of course, it depends, but there would be common validation logic for CSV files. For example, some columns may have to be integers because of database requirements. It would be cumbersome to write such validations always.

strong_csv helps you to mitigate such a drudgery by letting you declare desired types beforehand.

Installation

Add this line to your application's Gemfile:

gem "strong_csv"

And then execute:

bundle

Or install it yourself as:

gem install strong_csv

Usage

The most important APIs of strong_csv are StrongCSV.new and StrongCSV#parse. StrongCSV.new lets you declare types for each CSV column with Ruby's block syntax. Inside the block, you will mainly use let and declare types for a column.

After defining types, you can parse CSV content with StrongCSV#parse. StrongCSV#parse won't raise errors as possible and just store error messages in its rows. The reason why it won't raise errors is CSV content may contain invalid rows, but sometimes, it makes sense to ignore them and process something for valid rows. If you want to stop all processes with invalid rows, check whether all rows are valid before proceeding with computation.

Here is an example usage of this gem:

require "strong_csv"

strong_csv = StrongCSV.new do
  let :stock, integer
  let :tax_rate, float
  let :name, string(within: 1..255)
  let :description, string?(within: 1..1000)
  let :active, boolean
  let :started_at, time?(format: "%Y-%m-%dT%H:%M:%S")
  let :price, integer, error_message: "This should be Integer"

  # Literal declaration
  let :status, 0..6
  let :priority, 10, 20, 30, 40, 50
  let :size, "S", "M", "L" do |value| # The input must be one of "S", "M", or "L", and it will be casted as the returned value of the block.
    case value
    when "S"
      1
    when "M"
      2
    when "L"
      3
    end
  end

  # Regular expressions
  let :url, %r{\Ahttps://}

  # Custom validation
  #
  # This example sees the database to fetch exactly stored `User` IDs,
  # and it checks the `:user_id` cell really exists in the `users` table.
  # `pick` would be useful to avoid N+1 problems.
  pick :user_id, as: :user_ids do |ids|
    User.where(id: ids).ids
  end
  let :user_id, integer(constraint: ->(i) { user_ids.include?(i) })
end

data = <<~CSV
  stock,tax_rate,name,active,status,priority,size,url,price
  12,0.8,special item,True,4,20,M,https://example.com,PRICE
CSV

strong_csv.parse(data, field_size_limit: 2048) do |row|
  if row.valid?
    row[:tax_rate] # => 0.8
    row[:active] # => true
    # do something with row
  else
    row.errors # => {:price=>["This should be Integer"], :user_id=>["`nil` can't be casted to Integer"]}
    # do something with row.errors
  end
end

You can also define types without CSV headers by specifying column numbers.

Note

The numbers must start from 0 (zero-based index).

StrongCSV.new do
  let 0, integer
  let 1, string
  let 2, 1..10
end

This declaration expects a CSV has the contents like this:

123,abc,3
830,mno,10

Available types

Type Description
integer and integer? The value must be casted to Integer.
float and float? The value must be casted to Float.
boolean and boolean? The value must be casted to Boolean (true or false).
string and string? The value must be casted to String.
time and time? The value must be casted to Time.
optional The value can be nil. If the value exists, it must satisfy the given type constraint.
23 (Integer literal) The value must be casted to the specific Integer literal.
15.12 (Float literal) The value must be casted to the specific Float literal.
1..10 (Range literal) The value must be casted to the beginning of Range and be covered with it.
"abc" (String literal) The value must be casted to the specific String literal.
%r{\Ahttps://} (Regexp literal) The value must be casted to a String that matches the specified Regexp.
, (Union type) The value must satisfy one of the subtypes.

integer and integer?

The value must be casted to Integer. integer? allows the value to be nil, so you can declare optional integer type for columns. It also lets you allow values that satisfy the specified limitation through :constraint.

Example

strong_csv = StrongCSV.new do
  let :stock, integer
  let :state, integer?
  let :user_id, integer(constraint: ->(v) { user_ids.include?(v)})
  pick :user_id, as: :user_ids do |values|
    User.where(id: values).ids
  end
end

result = strong_csv.parse(<<~CSV)
  stock,state,user_id
  12,0,1
  20,,2
  non-integer,1,4
CSV

result.map(&:valid?) # => [true, true, false]
result[0].slice(:stock, :state, :user_id) # => {:stock=>12, :state=>0, :user_id=>1}
result[1].slice(:stock, :state, :user_id) # => {:stock=>20, :state=>nil, :user_id=>2}
result[2].slice(:stock, :state, :user_id) # => {:stock=>"non-integer", :state=>1, :user_id=>"4"}
result[2].errors.slice(:stock, :user_id) # => {:stock=>["`\"non-integer\"` can't be casted to Integer"], :user_id=>["`\"4\"` does not satisfy the specified constraint"]}

float and float?

The value must be casted to Float. float? allows the value to be nil, so you can declare optional float type for columns. It also lets you allow values that satisfy the specified limitation through :constraint.

Example

strong_csv = StrongCSV.new do
  let :tax_rate, float
  let :fail_rate, float?
end

result = strong_csv.parse(<<~CSV)
  tax_rate,fail_rate
  0.02,0.1
  0.05,
  ,0.8
CSV

result.map(&:valid?) # => [true, true, false]
result[0].slice(:tax_rate, :fail_rate) # => {:tax_rate=>0.02, :fail_rate=>0.1}
result[1].slice(:tax_rate, :fail_rate) # => {:tax_rate=>0.05, :fail_rate=>nil}
result[2].slice(:tax_rate, :fail_rate) # => {:tax_rate=>nil, :fail_rate=>0.8} (`nil` is not allowed for `tax_rate`)

boolean and boolean?

The value must be casted to Boolean (true of false). "true", "True", and "TRUE" are casted to true, while "false", "False", and "FALSE" are casted to false. boolean? allows the value to be nil as an optional boolean value.

Example

strong_csv = StrongCSV.new do
  let :enabled, boolean
  let :active, boolean?
end

result = strong_csv.parse(<<~CSV)
  enabled,active
  True,True
  False,
  ,
CSV

result.map(&:valid?) # => [true, true, false]
result[0].slice(:enabled, :active) # => {:enabled=>true, :active=>true}
result[1].slice(:enabled, :active) # => {:enabled=>false, :active=>nil}
result[2].slice(:enabled, :active) # => {:enabled=>nil, :active=>nil} (`nil` is not allowed for `enabled`)

string and string?

The value must be casted to String. string? allows the value to be nil as an optional string value. They also support :within in its arguments, and it limits the length of the string value within the specified Range.

Example

strong_csv = StrongCSV.new do
  let :name, string(within: 1..4)
  let :description, string?
end

result = strong_csv.parse(<<~CSV)
  name,description
  JB,Hello
  yykamei,
  ,🤷
CSV

result.map(&:valid?) # => [true, false, false]
result[0].slice(:name, :description) # => {:name=>"JB", :description=>"Hello"}
result[1].slice(:name, :description) # => {:name=>"yykamei", :description=>nil} ("yykamei" exceeds the `Range` specified with `:within`)
result[2].slice(:name, :description) # => {:name=>nil, :description=>"🤷"} (`nil` is not allowed for `name`)

time and time?

The value must be casted to Time. time? allows the value to be nil as an optional time value. They have the :format argument, which is used as the format of Time.strptime; it means you can ensure the value must satisfy the time format. The default value of :format is "%Y-%m-%d".

Example

strong_csv = StrongCSV.new do
  let :start_on, time
  let :updated_at, time?(format: "%FT%T")
end

result = strong_csv.parse(<<~CSV)
  start_on,updated_at
  2022-04-01,2022-04-30T15:30:59
  2022-05-03
  05-03,2021-09-03T09:48:23
CSV

result.map(&:valid?) # => [true, true, false]
result[0].slice(:start_on, :updated_at) # => {:start_on=>2022-04-01 00:00:00 +0900, :updated_at=>2022-04-30 15:30:59 +0900}
result[1].slice(:start_on, :updated_at) # => {:start_on=>2022-05-03 00:00:00 +0900, :updated_at=>nil}
result[2].slice(:start_on, :updated_at) # => {:start_on=>"05-03", :updated_at=>2021-09-03 09:48:23 +0900} ("05-03" does not satisfy the default format `"%Y-%m-%d"`)

optional

While each type above has its optional type with ?, literals cannot be suffixed with ?. However, there would be a case to have an optional literal type. In this case, optional might be useful and lets you declare such types.

Example

strong_csv = StrongCSV.new do
  let :foo, optional(123)
  let :bar, optional("test")
end

result = strong_csv.parse(<<~CSV)
  foo,bar
  123,test
  ,
  124
CSV

result.map(&:valid?) # => [true, true, false]
result[0].slice(:foo, :bar) # => {:foo=>123, :bar=>"test"}
result[1].slice(:foo, :bar) # => {:foo=>nil, :bar=>nil}
result[2].slice(:foo, :bar) # => {:foo=>"124", :bar=>nil} (124 is not equal to 123)

Literal

You can declare literal value as types. The supported literals are Integer, Float, String, and Range.

Example

strong_csv = StrongCSV.new do
  let 0, 123
  let 1, "test"
  let 2, 2.5
  let 3, 1..10
  let 4, /[a-z]+/
end

result = strong_csv.parse(<<~CSV)
  123,test,2.5,9,abc
  123,test,2.5,0,xyz
  123,Hey,2.5,10,!
CSV

result.map(&:valid?) # => [true, false, false]
result[0].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"test", 2=>2.5, 3=>9, 4=>"abc"}
result[1].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"test", 2=>2.5, 3=>"0", 4=>"xyz"} (0 is out of 1..10)
result[2].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"Hey", 2=>2.5, 3=>10, 4=>"!"} ("Hey" is not equal to "test", and "!" does not match /[a-z]+/)

Union

There would be a case that it's alright if a value satisfies one of the types. Union types are useful for such a case.

Example

strong_csv = StrongCSV.new do
  let :priority, 10, 20, 30
  let :size, "S", "M", "L"
end

result = strong_csv.parse(<<~CSV)
  priority,size
  10,M
  30,A
  11,S
CSV

result.map(&:valid?) # => [true, false, false]
result[0].slice(:priority, :size) # => {:priority=>10, :size=>"M"}
result[1].slice(:priority, :size) # => {:priority=>30, :size=>"A"} ("A" is not one of "S", "M", and "L")
result[2].slice(:priority, :size) # => {:priority=>"11", :size=>"S"} (11 is not one of 10, 20, and 30)

Contributing

Bug reports and pull requests are welcome on the GitHub repository. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.