strong_csv 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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