strong_csv 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a6324b338c8922470db715a4db9aac4d29a7e658313326df6b860a51eda09a5
4
- data.tar.gz: '095654b19ea937ca862c91b98e4c59d29f8ddd148d0cb2feac594edf1ea54a1f'
3
+ metadata.gz: 5c718425815c3296bf644f0206eb7f351a25565908eddf5ec9b5811c045a46ec
4
+ data.tar.gz: fb8912e844d913b06c026e46d0b78084f7f0b3e81738ef3cc48476d8e784d03d
5
5
  SHA512:
6
- metadata.gz: 9d729e901fcbe7b6cecbe7b0470d5dffaa4c51f7a11269b1f0b2db188c633ee46fb569fab333343268d9f2235ce790508bc0070e6d716888bdfb8ae08c75011c
7
- data.tar.gz: 3cfc376f86564afea311afae002488172c9b01ec4676791e7c78b3192495361ddc9f9531a57987c7235c7420acf04819c265c29213a9282c23a93c45af5aae4f
6
+ metadata.gz: cc0564f9036a150e8564ff3bd947d9e4d7197b25179d37ab1c0d9997c7517834c3c76d54b5e28a6c1c5631e5c64f2601989935052b21dd6d588fa7e521ec7c8d
7
+ data.tar.gz: 205d2e29e1512e1508328f724b63ec40cf1d138c8d3bf83e586f6f0d8d7730f2d8f5fae648a589650efaff1e6104c9cb56ae0b8771fb7098a34d34aa08c525ad
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # strong_csv
2
2
 
3
+ <a href="https://rubygems.org/gems/strong_csv"><img alt="strong_csv" src="https://img.shields.io/gem/v/strong_csv"></a>
4
+
3
5
  NOTE: This repository is still under development 🚧🚜🚧
4
6
 
5
7
  Type checker for a CSV file inspired by [strong_json](https://github.com/soutaro/strong_json).
@@ -21,7 +23,7 @@ strong_json helps you to mitigate such a drudgery by letting you declare desired
21
23
  Add this line to your application's Gemfile:
22
24
 
23
25
  ```ruby
24
- gem 'strong_csv'
26
+ gem "strong_csv"
25
27
  ```
26
28
 
27
29
  And then execute:
@@ -41,19 +43,21 @@ gem install strong_csv
41
43
  TBD: This hasn't yet been implemented.
42
44
 
43
45
  ```ruby
46
+ require "strong_csv"
47
+
44
48
  strong_csv = StrongCSV.new do
45
49
  let :stock, integer
46
50
  let :tax_rate, float
47
- let :name, string(255)
48
- let :description, string?(1000)
51
+ let :name, string(within: 1..255)
52
+ let :description, string?(within: 1..1000)
49
53
  let :active, boolean
50
54
  let :started_at, time?
51
55
  let :data, any?
52
56
 
53
57
  # Literal declaration
54
58
  let :status, 0..6
55
- let :priority, 10 | 20 | 30 | 40 | 50
56
- let :size, "S" | "M" | "L" do |value|
59
+ let :priority, 10, 20, 30, 40, 50
60
+ let :size, "S", "M", "L" do |value|
57
61
  case value
58
62
  when "S"
59
63
  1
@@ -75,7 +79,7 @@ strong_csv = StrongCSV.new do
75
79
  pick :user_id, as: :user_ids do |ids|
76
80
  User.where(id: ids).ids
77
81
  end
78
- let :user_id, integer { |i| user_ids.include?(i) }
82
+ let :user_id, integer(constraint: ->(i) { user_ids.include?(i) })
79
83
  end
80
84
 
81
85
  data = <<~CSV
@@ -89,7 +93,7 @@ strong_csv.parse(data, field_size_limit: 2048) do |row|
89
93
  row[:active] # => true
90
94
  # do something with row
91
95
  else
92
- row.errors # => [{ row: 2, column: :user_id, messages: ["must be present", "must be an Integer", "must satisfy the custom validation"] }]
96
+ row.errors # => { user_id: ["must be present", "must be an integer"] }
93
97
  # do something with row.errors
94
98
  end
95
99
  end
@@ -97,9 +101,24 @@ end
97
101
 
98
102
  ## Available types
99
103
 
100
- | Type | Description |
101
- | ------- | ----------------------------------- |
102
- | integer | The value must be casted to Integer |
104
+ | Type | Arguments | Description | Example |
105
+ | ------------------------ | --------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
106
+ | `integer` | | The value must be casted to Integer | `let :stock, integer` |
107
+ | `integer?` | | The value can be `nil`. If the value exists, it must satisfy `integer` constraint. | `let :stock, integer?` |
108
+ | `float` | | The value must be casted to Float | `let :rate, float` |
109
+ | `float?` | | The value can be `nil`. If the value exists, it must satisfy `float` constraint. | `let :rate, float?` |
110
+ | `boolean` | | The value must be casted to Boolean | `let :active, boolean` |
111
+ | `boolean?` | | The value can be `nil`. If the value exists, it must satisfy `boolean` constraint. | `let :active, boolean?` |
112
+ | `string` | `:within` | The value must be casted to String | `let :name, string(within: 1..255)` |
113
+ | `string?` | `:within` | The value can be `nil`. If the value exists, it must satisfy `string` constraint. | `let :name, string?(within: 1..255)` |
114
+ | `time` | `:format` | The value must be casted to Time | `let :started_at, time(format: "%Y-%m-%dT%%H:%M:%S")` |
115
+ | `time?` | `:format` | The value can be `nil`. If the value exists, it must satisfy `time` constraint. | `let :started_at, time?(format: "%Y-%m-%dT%%H:%M:%S")` |
116
+ | `optional` | `type` | The value can be `nil`. If the value exists, it must satisfy the given type constraint. | `let :foo, optional(123)` |
117
+ | `23` (Integer literal) | | The value must be casted to the specific Integer literal | `let :id, 3` |
118
+ | `15.12` (Float literal) | | The value must be casted to the specific Float literal | `let :id, 3.8` |
119
+ | `1..10` (Range literal) | | The value must be casted to the beginning of Range and be covered with it | `let :id, 10..30`, `let :id, 1.0..30`, `let :id, "a".."z"` |
120
+ | `"abc"` (String literal) | | The value must be casted to the specific String literal | `let :drink, "coffee"` |
121
+ | , (Union type) | | The value must satisfy one of the subtypes | `let :id, 1, 2, 3` |
103
122
 
104
123
  ## Contributing
105
124
 
@@ -3,7 +3,11 @@
3
3
  class StrongCSV
4
4
  # Let is a class that is used to define types for columns.
5
5
  class Let
6
- attr_reader :types, :headers
6
+ # @return [Hash{Symbol => [Types::Base, Proc]}]
7
+ attr_reader :types
8
+
9
+ # @return [Boolean]
10
+ attr_reader :headers
7
11
 
8
12
  def initialize
9
13
  @types = {}
@@ -11,12 +15,15 @@ class StrongCSV
11
15
  end
12
16
 
13
17
  # @param name [String, Symbol, Integer]
14
- def let(name, type)
18
+ # @param type [StrongCSV::Type::Base]
19
+ # @param types [Array<StrongCSV::Type::Base>]
20
+ def let(name, type, *types, &block)
21
+ type = types.empty? ? type : Types::Union.new(type, *types)
15
22
  case name
16
- when Integer
17
- @types[name] = type
18
- when String, Symbol
19
- @types[name.to_sym] = type
23
+ when ::Integer
24
+ @types[name] = [type, block]
25
+ when ::String, ::Symbol
26
+ @types[name.to_sym] = [type, block]
20
27
  else
21
28
  raise TypeError, "Invalid type specified for `name`. `name` must be String, Symbol, or Integer: #{name.inspect}"
22
29
  end
@@ -27,6 +34,49 @@ class StrongCSV
27
34
  Types::Integer.new
28
35
  end
29
36
 
37
+ def integer?
38
+ optional(integer)
39
+ end
40
+
41
+ def boolean
42
+ Types::Boolean.new
43
+ end
44
+
45
+ def boolean?
46
+ optional(boolean)
47
+ end
48
+
49
+ def float
50
+ Types::Float.new
51
+ end
52
+
53
+ def float?
54
+ optional(float)
55
+ end
56
+
57
+ # @param options [Hash] See `Types::String#initialize` for more details.
58
+ def string(**options)
59
+ Types::String.new(**options)
60
+ end
61
+
62
+ def string?(**options)
63
+ optional(string(**options))
64
+ end
65
+
66
+ # @param options [Hash] See `Types::Time#initialize` for more details.
67
+ def time(**options)
68
+ Types::Time.new(**options)
69
+ end
70
+
71
+ def time?(**options)
72
+ optional(time(**options))
73
+ end
74
+
75
+ # @param args [Array] See `Types::Optional#initialize` for more details.
76
+ def optional(*args)
77
+ Types::Optional.new(*args)
78
+ end
79
+
30
80
  private
31
81
 
32
82
  def validate_columns
@@ -4,6 +4,7 @@ class StrongCSV
4
4
  # Row is a representation of a row in a CSV file, which has casted values with specified types.
5
5
  class Row
6
6
  extend Forwardable
7
+ using Types::Literal
7
8
 
8
9
  def_delegators :@values, :[], :fetch
9
10
 
@@ -20,10 +21,10 @@ class StrongCSV
20
21
  @values = {}
21
22
  @errors = {}
22
23
  @lineno = lineno
23
- types.each do |key, type|
24
+ types.each do |key, (type, block)|
24
25
  value_result = type.cast(row[key])
25
- @values[key] = value_result.value
26
- @errors[key] = value_result.error_message unless value_result.success?
26
+ @values[key] = block && value_result.success? ? block.call(value_result.value) : value_result.value
27
+ @errors[key] = value_result.error_messages unless value_result.success?
27
28
  end
28
29
  end
29
30
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Boolean type
6
+ class Boolean < Base
7
+ TRUE_VALUES = %w[true True TRUE].to_set.freeze
8
+ FALSE_VALUES = %w[false False FALSE].to_set.freeze
9
+ private_constant :TRUE_VALUES, :FALSE_VALUES
10
+
11
+ # @param value [Object] Value to be casted to Boolean
12
+ # @return [ValueResult]
13
+ def cast(value)
14
+ boolean = TRUE_VALUES.include?(value) ? true : nil
15
+ return ValueResult.new(value: boolean, original_value: value) unless boolean.nil?
16
+
17
+ boolean = FALSE_VALUES.include?(value) ? false : nil
18
+ return ValueResult.new(value: boolean, original_value: value) unless boolean.nil?
19
+
20
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Boolean"])
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Float type
6
+ class Float < Base
7
+ # @todo Use :exception for Float after we drop the support of Ruby 2.5
8
+ # @param value [Object] Value to be casted to Float
9
+ # @return [ValueResult]
10
+ def cast(value)
11
+ ValueResult.new(value: Float(value), original_value: value)
12
+ rescue ArgumentError, TypeError
13
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Float"])
14
+ end
15
+ end
16
+ end
17
+ end
@@ -5,10 +5,12 @@ class StrongCSV
5
5
  # Integer type
6
6
  class Integer < Base
7
7
  # @todo Use :exception for Integer after we drop the support of Ruby 2.5
8
+ # @param value [Object] Value to be casted to Integer
9
+ # @return [ValueResult]
8
10
  def cast(value)
9
11
  ValueResult.new(value: Integer(value), original_value: value)
10
12
  rescue ArgumentError, TypeError
11
- ValueResult.new(original_value: value, error_message: "`#{value.inspect}` can't be casted to Integer")
13
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Integer"])
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Literal is a module to collect all the literal types for CSV parsing.
6
+ # This module is intended for refining built-in classes and provide `#cast` like `Base` class.
7
+ module Literal
8
+ refine ::Integer do
9
+ # @param value [Object] Value to be casted to Integer
10
+ # @return [ValueResult]
11
+ def cast(value)
12
+ int = Integer(value)
13
+ if int == self
14
+ ValueResult.new(value: int, original_value: value)
15
+ else
16
+ ValueResult.new(original_value: value, error_messages: ["`#{inspect}` is expected, but `#{int}` was given"])
17
+ end
18
+ rescue ArgumentError, TypeError
19
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to #{self.class.name}"])
20
+ end
21
+ end
22
+
23
+ refine ::Float do
24
+ # @param value [Object] Value to be casted to Float
25
+ # @return [ValueResult]
26
+ def cast(value)
27
+ float = Float(value)
28
+ if float == self
29
+ ValueResult.new(value: float, original_value: value)
30
+ else
31
+ ValueResult.new(original_value: value, error_messages: ["`#{inspect}` is expected, but `#{float}` was given"])
32
+ end
33
+ rescue ArgumentError, TypeError
34
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to #{self.class.name}"])
35
+ end
36
+ end
37
+
38
+ refine ::Range do
39
+ # @param value [Object] Value to be casted to Range
40
+ # @return [ValueResult]
41
+ def cast(value)
42
+ return ValueResult.new(original_value: value, error_messages: ["`nil` can't be casted to the beginning of `#{inspect}`"]) if value.nil?
43
+
44
+ casted = case self.begin
45
+ when ::Float
46
+ Float(value)
47
+ when ::Integer
48
+ Integer(value)
49
+ when ::String
50
+ value
51
+ else
52
+ raise TypeError, "#{self.begin.class} is not supported"
53
+ end
54
+ if cover?(casted)
55
+ ValueResult.new(value: casted, original_value: value)
56
+ else
57
+ ValueResult.new(original_value: value, error_messages: ["`#{casted.inspect}` is not within `#{inspect}`"])
58
+ end
59
+ rescue ArgumentError
60
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to the beginning of `#{inspect}`"])
61
+ end
62
+ end
63
+
64
+ refine ::String do
65
+ # @param value [Object] Value to be casted to String
66
+ # @return [ValueResult]
67
+ def cast(value)
68
+ if self == value
69
+ ValueResult.new(value: self, original_value: value)
70
+ else
71
+ ValueResult.new(original_value: value, error_messages: ["`#{inspect}` is expected, but `#{value.inspect}` was given"])
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Optional type
6
+ class Optional < Base
7
+ using Types::Literal
8
+
9
+ # @param type [Base]
10
+ def initialize(type)
11
+ super()
12
+ @type = type
13
+ end
14
+
15
+ # @param value [Object]
16
+ # @return [ValueResult]
17
+ def cast(value)
18
+ value.nil? ? ValueResult.new(value: nil, original_value: value) : @type.cast(value)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # String type
6
+ class String < Base
7
+ # @param within [Range, nil]
8
+ def initialize(within: nil)
9
+ raise ArgumentError, "`within` must be a Range" unless within.nil? || within.is_a?(Range)
10
+
11
+ super()
12
+ @within = within
13
+ end
14
+
15
+ # @param value [Object] Value to be casted to Boolean
16
+ # @return [ValueResult]
17
+ def cast(value)
18
+ return ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to String"]) if value.nil?
19
+
20
+ casted = String(value)
21
+ if @within && !@within.cover?(casted.size)
22
+ ValueResult.new(original_value: value, error_messages: ["`#{casted.inspect}` is out of range `#{@within.inspect}`"])
23
+ else
24
+ ValueResult.new(value: casted, original_value: value)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Time type
6
+ class Time < Base
7
+ # @param format [String] The format of #strptime
8
+ def initialize(format: "%Y-%m-%d")
9
+ raise ArgumentError, "`format` must be a String" unless format.is_a?(::String)
10
+
11
+ super()
12
+ @format = format
13
+ end
14
+
15
+ # @param value [Object] Value to be casted to Time
16
+ # @return [ValueResult]
17
+ def cast(value)
18
+ return ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Time"]) if value.nil?
19
+
20
+ ValueResult.new(value: ::Time.strptime(value.to_s, @format), original_value: value)
21
+ rescue ArgumentError
22
+ ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Time with the format `#{@format}`"])
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Union type is a type that combine multiple types.
6
+ class Union < Base
7
+ using Types::Literal
8
+
9
+ # @param type [Base]
10
+ # @param types [Array<Base>]
11
+ def initialize(type, *types)
12
+ super()
13
+ @types = [type, *types]
14
+ end
15
+
16
+ # @param value [Object] Value to be casted to Integer
17
+ # @return [ValueResult]
18
+ def cast(value)
19
+ results = @types.map { |type| type.cast(value) }
20
+ results.find(&:success?) || results.reduce do |memo, result|
21
+ memo.error_messages.concat(result.error_messages).uniq!
22
+ memo
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,13 +6,13 @@ class StrongCSV
6
6
  DEFAULT_VALUE = Object.new
7
7
  private_constant :DEFAULT_VALUE
8
8
 
9
- # @return [String, nil] The error message for the field.
10
- attr_reader :error_message
9
+ # @return [Array<String>, nil] The error messages for the field.
10
+ attr_reader :error_messages
11
11
 
12
- def initialize(original_value:, value: DEFAULT_VALUE, error_message: nil)
12
+ def initialize(original_value:, value: DEFAULT_VALUE, error_messages: nil)
13
13
  @value = value
14
14
  @original_value = original_value
15
- @error_message = error_message
15
+ @error_messages = error_messages
16
16
  end
17
17
 
18
18
  # @return [Object] The casted value if it's valid. Otherwise, returns the original value.
@@ -22,7 +22,7 @@ class StrongCSV
22
22
 
23
23
  # @return [Boolean]
24
24
  def success?
25
- @error_message.nil?
25
+ @error_messages.nil?
26
26
  end
27
27
  end
28
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class StrongCSV
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/strong_csv.rb CHANGED
@@ -2,12 +2,21 @@
2
2
 
3
3
  require "csv"
4
4
  require "forwardable"
5
+ require "set"
6
+ require "time"
5
7
 
6
8
  require_relative "strong_csv/version"
7
- require_relative "strong_csv/let"
8
9
  require_relative "strong_csv/value_result"
9
10
  require_relative "strong_csv/types/base"
11
+ require_relative "strong_csv/types/boolean"
12
+ require_relative "strong_csv/types/float"
10
13
  require_relative "strong_csv/types/integer"
14
+ require_relative "strong_csv/types/literal"
15
+ require_relative "strong_csv/types/optional"
16
+ require_relative "strong_csv/types/string"
17
+ require_relative "strong_csv/types/time"
18
+ require_relative "strong_csv/types/union"
19
+ require_relative "strong_csv/let"
11
20
  require_relative "strong_csv/row"
12
21
 
13
22
  # The top-level namespace for the strong_csv gem.
@@ -22,6 +31,8 @@ class StrongCSV
22
31
  # @param csv [String, IO]
23
32
  # @param options [Hash] CSV options for parsing.
24
33
  def parse(csv, **options)
34
+ # NOTE: Some options are overridden here to ensure that StrongCSV can handle parsed values correctly.
35
+ options.delete(:nil_value)
25
36
  options = options.merge(headers: @let.headers, header_converters: :symbol)
26
37
  csv = CSV.new(csv, **options)
27
38
  if block_given?
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strong_csv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yutaka Kamei
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2022-04-26 00:00:00.000000000 Z
11
+ date: 2022-04-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: strong_csv is a type checker for a CSV file. It lets developers declare
14
14
  types for each column to ensure all cells are satisfied with desired types.
@@ -24,7 +24,14 @@ files:
24
24
  - lib/strong_csv/let.rb
25
25
  - lib/strong_csv/row.rb
26
26
  - lib/strong_csv/types/base.rb
27
+ - lib/strong_csv/types/boolean.rb
28
+ - lib/strong_csv/types/float.rb
27
29
  - lib/strong_csv/types/integer.rb
30
+ - lib/strong_csv/types/literal.rb
31
+ - lib/strong_csv/types/optional.rb
32
+ - lib/strong_csv/types/string.rb
33
+ - lib/strong_csv/types/time.rb
34
+ - lib/strong_csv/types/union.rb
28
35
  - lib/strong_csv/value_result.rb
29
36
  - lib/strong_csv/version.rb
30
37
  homepage: https://github.com/yykamei/strong_csv