strong_csv 0.1.0 → 0.4.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 +4 -4
- data/README.md +334 -15
- data/lib/strong_csv/i18n.rb +45 -0
- data/lib/strong_csv/let.rb +56 -6
- data/lib/strong_csv/row.rb +5 -4
- data/lib/strong_csv/types/boolean.rb +24 -0
- data/lib/strong_csv/types/float.rb +17 -0
- data/lib/strong_csv/types/integer.rb +3 -1
- data/lib/strong_csv/types/literal.rb +91 -0
- data/lib/strong_csv/types/optional.rb +22 -0
- data/lib/strong_csv/types/string.rb +29 -0
- data/lib/strong_csv/types/time.rb +26 -0
- data/lib/strong_csv/types/union.rb +27 -0
- data/lib/strong_csv/value_result.rb +5 -5
- data/lib/strong_csv/version.rb +1 -1
- data/lib/strong_csv.rb +14 -1
- metadata +32 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8078bf75d84307eb32f647a418d2cd2214d6891a78a25a73f7589b82fec0716c
|
4
|
+
data.tar.gz: 318af2a0c3a42ac7141819b06e729a5e98967c5106988bc4349b308f9c9d87f8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 63aa30f83182e0cab08f816124945d9da91149faaa82017ba0a80ca8b39ef57fed5eab521db1efd8404045aadba189550bd779f195434cef2294975dc30216f3
|
7
|
+
data.tar.gz: e4e74ea2446efcf32f6e67fe8be5df207ae8dbd66b392df4c2fb8895cd71aebf70894d062e64eee2814295aa00f29876afcb95b4c3a88ffffd7be0c8e9a9ba21
|
data/README.md
CHANGED
@@ -1,10 +1,12 @@
|
|
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).
|
6
8
|
|
7
|
-
|
9
|
+
## Motivation
|
8
10
|
|
9
11
|
Some applications have a feature to receive a CSV file uploaded by a user,
|
10
12
|
and in general, it needs to validate each cell of the CSV file.
|
@@ -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
|
26
|
+
gem "strong_csv"
|
25
27
|
```
|
26
28
|
|
27
29
|
And then execute:
|
@@ -38,22 +40,34 @@ gem install strong_csv
|
|
38
40
|
|
39
41
|
## Usage
|
40
42
|
|
41
|
-
|
43
|
+
The most important APIs of strong_csv are `StrongCSV.new` and `StrongCSV#parse`.
|
44
|
+
`StrongCSV.new` lets you declare types for each CSV column with Ruby's block syntax.
|
45
|
+
Inside the block, you will mainly use `let` and declare types for a column.
|
46
|
+
|
47
|
+
After defining types, you can parse CSV content with `StrongCSV#parse`.
|
48
|
+
`StrongCSV#parse` won't raise errors as possible and just store error messages in its rows.
|
49
|
+
The reason why it won't raise errors is CSV content may contain _invalid_ rows,
|
50
|
+
but sometimes, it makes sense to ignore them and process something for _valid_ rows.
|
51
|
+
If you want to stop all processes with invalid rows,
|
52
|
+
check whether all rows are valid before proceeding with computation.
|
53
|
+
|
54
|
+
Here is an example usage of this gem:
|
42
55
|
|
43
56
|
```ruby
|
57
|
+
require "strong_csv"
|
58
|
+
|
44
59
|
strong_csv = StrongCSV.new do
|
45
60
|
let :stock, integer
|
46
61
|
let :tax_rate, float
|
47
|
-
let :name, string(255)
|
48
|
-
let :description, string?(1000)
|
62
|
+
let :name, string(within: 1..255)
|
63
|
+
let :description, string?(within: 1..1000)
|
49
64
|
let :active, boolean
|
50
|
-
let :started_at, time?
|
51
|
-
let :data, any?
|
65
|
+
let :started_at, time?(format: "%Y-%m-%dT%H:%M:%S")
|
52
66
|
|
53
67
|
# Literal declaration
|
54
68
|
let :status, 0..6
|
55
|
-
let :priority, 10
|
56
|
-
let :size, "S"
|
69
|
+
let :priority, 10, 20, 30, 40, 50
|
70
|
+
let :size, "S", "M", "L" do |value|
|
57
71
|
case value
|
58
72
|
when "S"
|
59
73
|
1
|
@@ -67,7 +81,9 @@ strong_csv = StrongCSV.new do
|
|
67
81
|
# Regular expressions
|
68
82
|
let :url, %r{\Ahttps://}
|
69
83
|
|
70
|
-
#
|
84
|
+
# TODO: The followings are not implemented so far.
|
85
|
+
|
86
|
+
# Custom validation
|
71
87
|
#
|
72
88
|
# This example sees the database to fetch exactly stored `User` IDs,
|
73
89
|
# and it checks the `:user_id` cell really exists in the `users` table.
|
@@ -75,7 +91,7 @@ strong_csv = StrongCSV.new do
|
|
75
91
|
pick :user_id, as: :user_ids do |ids|
|
76
92
|
User.where(id: ids).ids
|
77
93
|
end
|
78
|
-
let :user_id, integer {
|
94
|
+
let :user_id, integer(constraint: ->(i) { user_ids.include?(i) })
|
79
95
|
end
|
80
96
|
|
81
97
|
data = <<~CSV
|
@@ -89,7 +105,7 @@ strong_csv.parse(data, field_size_limit: 2048) do |row|
|
|
89
105
|
row[:active] # => true
|
90
106
|
# do something with row
|
91
107
|
else
|
92
|
-
row.errors # =>
|
108
|
+
row.errors # => { user_id: ["must be present", "must be an integer"] }
|
93
109
|
# do something with row.errors
|
94
110
|
end
|
95
111
|
end
|
@@ -97,9 +113,312 @@ end
|
|
97
113
|
|
98
114
|
## Available types
|
99
115
|
|
100
|
-
|
101
|
-
|
102
|
-
|
116
|
+
<table>
|
117
|
+
<tr>
|
118
|
+
<th>Type</th>
|
119
|
+
<th>Description</th>
|
120
|
+
</tr>
|
121
|
+
<tr>
|
122
|
+
<td><a href="#integer-and-integer"><code>integer</code> and <code>integer?</code></a></td>
|
123
|
+
<td>The value must be casted to <code>Integer</code>.</td>
|
124
|
+
</tr>
|
125
|
+
<tr>
|
126
|
+
<td><a href="#float-and-float"><code>float</code> and <code>float?</code></a></td>
|
127
|
+
<td>The value must be casted to <code>Float</code>.</td>
|
128
|
+
</tr>
|
129
|
+
<tr>
|
130
|
+
<td><a href="#boolean-and-boolean"><code>boolean</code> and <code>boolean?</code></a></td>
|
131
|
+
<td>The value must be casted to Boolean (<code>true</code> or <code>false</code>).</td>
|
132
|
+
</tr>
|
133
|
+
<tr>
|
134
|
+
<td><a href="#string-and-string"><code>string</code> and <code>string?</code></a></td>
|
135
|
+
<td>The value must be casted to <code>String</code>.</td>
|
136
|
+
</tr>
|
137
|
+
<tr>
|
138
|
+
<td><a href="#time-and-time"><code>time</code> and <code>time?</code></a></td>
|
139
|
+
<td>The value must be casted to <code>Time</code>.</td>
|
140
|
+
</tr>
|
141
|
+
<tr>
|
142
|
+
<td><a href="#optional"><code>optional</code></a></td>
|
143
|
+
<td>The value can be <code>nil</code>. If the value exists, it must satisfy the given type constraint.</td>
|
144
|
+
</tr>
|
145
|
+
<tr>
|
146
|
+
<td><a href="#literal"><code>23</code> (Integer literal)</a></td>
|
147
|
+
<td>The value must be casted to the specific <code>Integer</code> literal.</td>
|
148
|
+
</tr>
|
149
|
+
<tr>
|
150
|
+
<td><a href="#literal"><code>15.12</code> (Float literal)</a></td>
|
151
|
+
<td>The value must be casted to the specific <code>Float</code> literal.</td>
|
152
|
+
</tr>
|
153
|
+
<tr>
|
154
|
+
<td><a href="#literal"><code>1..10</code> (Range literal)</a></td>
|
155
|
+
<td>The value must be casted to the beginning of <code>Range</code> and be covered with it.</td>
|
156
|
+
</tr>
|
157
|
+
<tr>
|
158
|
+
<td><a href="#literal"><code>"abc"</code> (String literal)</a></td>
|
159
|
+
<td>The value must be casted to the specific <code>String</code> literal.</td>
|
160
|
+
</tr>
|
161
|
+
<tr>
|
162
|
+
<td><a href="#literal"><code>%r{\Ahttps://}</code> (Regexp literal)</a></td>
|
163
|
+
<td>The value must be casted to a <code>String</code> that matches the specified Regexp.</td>
|
164
|
+
</tr>
|
165
|
+
<tr>
|
166
|
+
<td><a href="#union"><code>,</code> (Union type)</a></td>
|
167
|
+
<td>The value must satisfy one of the subtypes.</td>
|
168
|
+
</tr>
|
169
|
+
</table>
|
170
|
+
|
171
|
+
### `integer` and `integer?`
|
172
|
+
|
173
|
+
The value must be casted to Integer. `integer?` allows the value to be `nil`, so you can declare optional integer type
|
174
|
+
for columns.
|
175
|
+
|
176
|
+
_Example_
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
strong_csv = StrongCSV.new do
|
180
|
+
let :stock, integer
|
181
|
+
let :state, integer?
|
182
|
+
end
|
183
|
+
|
184
|
+
result = strong_csv.parse(<<~CSV)
|
185
|
+
stock,state
|
186
|
+
12,0
|
187
|
+
20,
|
188
|
+
non-integer,1
|
189
|
+
CSV
|
190
|
+
|
191
|
+
result.map(&:valid?) # => [true, true, false]
|
192
|
+
result[0].slice(:stock, :state) # => {:stock=>12, :state=>0}
|
193
|
+
result[1].slice(:stock, :state) # => {:stock=>20, :state=>nil}
|
194
|
+
result[2].slice(:stock, :state) # => {:stock=>"non-integer", :state=>1} ("non-integer" cannot be casted to Integer)
|
195
|
+
```
|
196
|
+
|
197
|
+
### `float` and `float?`
|
198
|
+
|
199
|
+
The value must be casted to Float. `float?` allows the value to be `nil`, so you can declare optional float type for
|
200
|
+
columns.
|
201
|
+
|
202
|
+
_Example_
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
strong_csv = StrongCSV.new do
|
206
|
+
let :tax_rate, float
|
207
|
+
let :fail_rate, float?
|
208
|
+
end
|
209
|
+
|
210
|
+
result = strong_csv.parse(<<~CSV)
|
211
|
+
tax_rate,fail_rate
|
212
|
+
0.02,0.1
|
213
|
+
0.05,
|
214
|
+
,0.8
|
215
|
+
CSV
|
216
|
+
|
217
|
+
result.map(&:valid?) # => [true, true, false]
|
218
|
+
result[0].slice(:tax_rate, :fail_rate) # => {:tax_rate=>0.02, :fail_rate=>0.1}
|
219
|
+
result[1].slice(:tax_rate, :fail_rate) # => {:tax_rate=>0.05, :fail_rate=>nil}
|
220
|
+
result[2].slice(:tax_rate, :fail_rate) # => {:tax_rate=>nil, :fail_rate=>0.8} (`nil` is not allowed for `tax_rate`)
|
221
|
+
```
|
222
|
+
|
223
|
+
### `boolean` and `boolean?`
|
224
|
+
|
225
|
+
The value must be casted to Boolean (`true` of `false`).
|
226
|
+
`"true"`, `"True"`, and `"TRUE"` are casted to `true`,
|
227
|
+
while `"false"`, `"False"`, and `"FALSE"` are casted to `false`.
|
228
|
+
`boolean?` allows the value to be `nil` as an optional boolean
|
229
|
+
value.
|
230
|
+
|
231
|
+
_Example_
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
strong_csv = StrongCSV.new do
|
235
|
+
let :enabled, boolean
|
236
|
+
let :active, boolean?
|
237
|
+
end
|
238
|
+
|
239
|
+
result = strong_csv.parse(<<~CSV)
|
240
|
+
enabled,active
|
241
|
+
True,True
|
242
|
+
False,
|
243
|
+
,
|
244
|
+
CSV
|
245
|
+
|
246
|
+
result.map(&:valid?) # => [true, true, false]
|
247
|
+
result[0].slice(:enabled, :active) # => {:enabled=>true, :active=>true}
|
248
|
+
result[1].slice(:enabled, :active) # => {:enabled=>false, :active=>nil}
|
249
|
+
result[2].slice(:enabled, :active) # => {:enabled=>nil, :active=>nil} (`nil` is not allowed for `enabled`)
|
250
|
+
```
|
251
|
+
|
252
|
+
### `string` and `string?`
|
253
|
+
|
254
|
+
The value must be casted to String. `string?` allows the value to be `nil` as an optional string value.
|
255
|
+
They also support `:within` in its arguments, and it limits the length of the string value within the specified `Range`.
|
256
|
+
|
257
|
+
_Example_
|
258
|
+
|
259
|
+
```ruby
|
260
|
+
strong_csv = StrongCSV.new do
|
261
|
+
let :name, string(within: 1..4)
|
262
|
+
let :description, string?
|
263
|
+
end
|
264
|
+
|
265
|
+
result = strong_csv.parse(<<~CSV)
|
266
|
+
name,description
|
267
|
+
JB,Hello
|
268
|
+
yykamei,
|
269
|
+
,🤷
|
270
|
+
CSV
|
271
|
+
|
272
|
+
result.map(&:valid?) # => [true, false, false]
|
273
|
+
result[0].slice(:name, :description) # => {:name=>"JB", :description=>"Hello"}
|
274
|
+
result[1].slice(:name, :description) # => {:name=>"yykamei", :description=>nil} ("yykamei" exceeds the `Range` specified with `:within`)
|
275
|
+
result[2].slice(:name, :description) # => {:name=>nil, :description=>"🤷"} (`nil` is not allowed for `name`)
|
276
|
+
```
|
277
|
+
|
278
|
+
### `time` and `time?`
|
279
|
+
|
280
|
+
The value must be casted to Time. `time?` allows the value to be `nil` as an optional time value.
|
281
|
+
They have the `:format` argument, which is used as the format
|
282
|
+
of [`Time.strptime`](https://rubydoc.info/stdlib/time/Time.strptime);
|
283
|
+
it means you can ensure the value must satisfy the time format. The default value of `:format` is `"%Y-%m-%d"`.
|
284
|
+
|
285
|
+
_Example_
|
286
|
+
|
287
|
+
```ruby
|
288
|
+
strong_csv = StrongCSV.new do
|
289
|
+
let :start_on, time
|
290
|
+
let :updated_at, time?(format: "%FT%T")
|
291
|
+
end
|
292
|
+
|
293
|
+
result = strong_csv.parse(<<~CSV)
|
294
|
+
start_on,updated_at
|
295
|
+
2022-04-01,2022-04-30T15:30:59
|
296
|
+
2022-05-03
|
297
|
+
05-03,2021-09-03T09:48:23
|
298
|
+
CSV
|
299
|
+
|
300
|
+
result.map(&:valid?) # => [true, true, false]
|
301
|
+
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}
|
302
|
+
result[1].slice(:start_on, :updated_at) # => {:start_on=>2022-05-03 00:00:00 +0900, :updated_at=>nil}
|
303
|
+
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"`)
|
304
|
+
```
|
305
|
+
|
306
|
+
### `optional`
|
307
|
+
|
308
|
+
While each type above has its optional type with `?`, literals cannot be suffixed with `?`.
|
309
|
+
However, there would be a case to have an optional literal type.
|
310
|
+
In this case, `optional` might be useful and lets you declare such types.
|
311
|
+
|
312
|
+
_Example_
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
strong_csv = StrongCSV.new do
|
316
|
+
let :foo, optional(123)
|
317
|
+
let :bar, optional("test")
|
318
|
+
end
|
319
|
+
|
320
|
+
result = strong_csv.parse(<<~CSV)
|
321
|
+
foo,bar
|
322
|
+
123,test
|
323
|
+
,
|
324
|
+
124
|
325
|
+
CSV
|
326
|
+
|
327
|
+
result.map(&:valid?) # => [true, true, false]
|
328
|
+
result[0].slice(:foo, :bar) # => {:foo=>123, :bar=>"test"}
|
329
|
+
result[1].slice(:foo, :bar) # => {:foo=>nil, :bar=>nil}
|
330
|
+
result[2].slice(:foo, :bar) # => {:foo=>"124", :bar=>nil} (124 is not equal to 123)
|
331
|
+
```
|
332
|
+
|
333
|
+
### Literal
|
334
|
+
|
335
|
+
You can declare literal value as types. The supported literals are `Integer`, `Float`, `String`, and `Range`.
|
336
|
+
|
337
|
+
_Example_
|
338
|
+
|
339
|
+
```ruby
|
340
|
+
strong_csv = StrongCSV.new do
|
341
|
+
let 0, 123
|
342
|
+
let 1, "test"
|
343
|
+
let 2, 2.5
|
344
|
+
let 3, 1..10
|
345
|
+
let 4, /[a-z]+/
|
346
|
+
end
|
347
|
+
|
348
|
+
result = strong_csv.parse(<<~CSV)
|
349
|
+
123,test,2.5,9,abc
|
350
|
+
123,test,2.5,0,xyz
|
351
|
+
123,Hey,2.5,10,!
|
352
|
+
CSV
|
353
|
+
|
354
|
+
result.map(&:valid?) # => [true, false, false]
|
355
|
+
result[0].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"test", 2=>2.5, 3=>9, 4=>"abc"}
|
356
|
+
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)
|
357
|
+
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]+/)
|
358
|
+
```
|
359
|
+
|
360
|
+
### Union
|
361
|
+
|
362
|
+
There would be a case that it's alright if a value satisfies one of the types.
|
363
|
+
Union types are useful for such a case.
|
364
|
+
|
365
|
+
_Example_
|
366
|
+
|
367
|
+
```ruby
|
368
|
+
strong_csv = StrongCSV.new do
|
369
|
+
let :priority, 10, 20, 30
|
370
|
+
let :size, "S", "M", "L"
|
371
|
+
end
|
372
|
+
|
373
|
+
result = strong_csv.parse(<<~CSV)
|
374
|
+
priority,size
|
375
|
+
10,M
|
376
|
+
30,A
|
377
|
+
11,S
|
378
|
+
CSV
|
379
|
+
|
380
|
+
result.map(&:valid?) # => [true, false, false]
|
381
|
+
result[0].slice(:priority, :size) # => {:priority=>10, :size=>"M"}
|
382
|
+
result[1].slice(:priority, :size) # => {:priority=>30, :size=>"A"} ("A" is not one of "S", "M", and "L")
|
383
|
+
result[2].slice(:priority, :size) # => {:priority=>"11", :size=>"S"} (11 is not one of 10, 20, and 30)
|
384
|
+
```
|
385
|
+
|
386
|
+
## I18n (Internationalization)
|
387
|
+
|
388
|
+
strong_csv depends on [i18n](https://rubygems.org/gems/i18n) for internationalization.
|
389
|
+
If you want to have a locale-specific error message, put the message catalog in your locale files.
|
390
|
+
Here is an example of a locale file.
|
391
|
+
|
392
|
+
```yaml
|
393
|
+
ja:
|
394
|
+
strong_csv:
|
395
|
+
boolean:
|
396
|
+
cant_be_casted: "`%{value}`はBooleanに変換できません"
|
397
|
+
float:
|
398
|
+
cant_be_casted: "`%{value}`はFloatに変換できません"
|
399
|
+
integer:
|
400
|
+
cant_be_casted: "`%{value}`はIntegerに変換できません"
|
401
|
+
literal:
|
402
|
+
integer:
|
403
|
+
unexpected: "`%{expected}`ではなく`%{value}`が入力されています"
|
404
|
+
cant_be_casted: "`%{expected}`ではなく`%{value}`が入力されています"
|
405
|
+
float:
|
406
|
+
unexpected: "`%{expected}`ではなく`%{value}`が入力されています"
|
407
|
+
cant_be_casted: "`%{value}`はFloatに変換できません"
|
408
|
+
string:
|
409
|
+
unexpected: "`%{expected}`ではなく`%{value}`が入力されています"
|
410
|
+
range:
|
411
|
+
cant_be_casted: "`%{value}`は`%{expected}`の始端に変換できません"
|
412
|
+
out_of_range: "`%{value}`は`%{range}`の範囲外です"
|
413
|
+
regexp:
|
414
|
+
cant_be_casted: "`%{value}`はStringに変換できません"
|
415
|
+
unexpected: "`%{value}`は`%{expected}`とマッチしませんでした"
|
416
|
+
string:
|
417
|
+
cant_be_casted: "`%{value}`はStringに変換できません"
|
418
|
+
out_of_range: "`%{value}`の文字数は`%{range}`の範囲外です"
|
419
|
+
time:
|
420
|
+
cant_be_casted: "`%{value}`は`%{time_format}`でTimeに変換できません"
|
421
|
+
```
|
103
422
|
|
104
423
|
## Contributing
|
105
424
|
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
I18n.backend.store_translations(
|
4
|
+
:en, {
|
5
|
+
_strong_csv: {
|
6
|
+
boolean: {
|
7
|
+
cant_be_casted: "`%{value}` can't be casted to Boolean",
|
8
|
+
},
|
9
|
+
float: {
|
10
|
+
cant_be_casted: "`%{value}` can't be casted to Float",
|
11
|
+
},
|
12
|
+
integer: {
|
13
|
+
cant_be_casted: "`%{value}` can't be casted to Integer",
|
14
|
+
},
|
15
|
+
literal: {
|
16
|
+
integer: {
|
17
|
+
unexpected: "`%{expected}` is expected, but `%{value}` was given",
|
18
|
+
cant_be_casted: "`%{value}` can't be casted to Integer",
|
19
|
+
},
|
20
|
+
float: {
|
21
|
+
unexpected: "`%{expected}` is expected, but `%{value}` was given",
|
22
|
+
cant_be_casted: "`%{value}` can't be casted to Float",
|
23
|
+
},
|
24
|
+
string: {
|
25
|
+
unexpected: "`%{expected}` is expected, but `%{value}` was given",
|
26
|
+
},
|
27
|
+
range: {
|
28
|
+
cant_be_casted: "`%{value}` can't be casted to the beginning of `%{expected}`",
|
29
|
+
out_of_range: "`%{value}` is not within `%{range}`",
|
30
|
+
},
|
31
|
+
regexp: {
|
32
|
+
cant_be_casted: "`%{value}` can't be casted to String",
|
33
|
+
unexpected: "`%{value}` did not match `%{expected}`",
|
34
|
+
},
|
35
|
+
},
|
36
|
+
string: {
|
37
|
+
cant_be_casted: "`%{value}` can't be casted to String",
|
38
|
+
out_of_range: "The length of `%{value}` is out of range `%{range}`",
|
39
|
+
},
|
40
|
+
time: {
|
41
|
+
cant_be_casted: "`%{value}` can't be casted to Time with the format `%{time_format}`",
|
42
|
+
},
|
43
|
+
},
|
44
|
+
},
|
45
|
+
)
|
data/lib/strong_csv/let.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
data/lib/strong_csv/row.rb
CHANGED
@@ -4,8 +4,9 @@ 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
|
-
def_delegators :@values, :[], :fetch
|
9
|
+
def_delegators :@values, :[], :fetch, :slice
|
9
10
|
|
10
11
|
# @return [Hash]
|
11
12
|
attr_reader :errors
|
@@ -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.
|
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: [I18n.t("strong_csv.boolean.cant_be_casted", value: value.inspect, default: :"_strong_csv.boolean.cant_be_casted")])
|
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: [I18n.t("strong_csv.float.cant_be_casted", value: value.inspect, default: :"_strong_csv.float.cant_be_casted")])
|
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,
|
13
|
+
ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.integer.cant_be_casted", value: value.inspect, default: :"_strong_csv.integer.cant_be_casted")])
|
12
14
|
end
|
13
15
|
end
|
14
16
|
end
|
@@ -0,0 +1,91 @@
|
|
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: [I18n.t("strong_csv.literal.integer.unexpected", value: int.inspect, expected: inspect, default: :"_strong_csv.literal.integer.unexpected")])
|
17
|
+
end
|
18
|
+
rescue ArgumentError, TypeError
|
19
|
+
ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.integer.cant_be_casted", value: value.inspect, default: :"_strong_csv.literal.integer.cant_be_casted")])
|
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: [I18n.t("strong_csv.literal.float.unexpected", value: float.inspect, expected: inspect, default: :"_strong_csv.literal.float.unexpected")])
|
32
|
+
end
|
33
|
+
rescue ArgumentError, TypeError
|
34
|
+
ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.float.cant_be_casted", value: value.inspect, default: :"_strong_csv.literal.float.cant_be_casted")])
|
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: [I18n.t("strong_csv.literal.range.cant_be_casted", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.range.cant_be_casted")]) 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: [I18n.t("strong_csv.literal.range.out_of_range", value: casted.inspect, range: inspect, default: :"_strong_csv.literal.range.out_of_range")])
|
58
|
+
end
|
59
|
+
rescue ArgumentError
|
60
|
+
ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.range.cant_be_casted", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.range.cant_be_casted")])
|
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: [I18n.t("strong_csv.literal.string.unexpected", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.string.unexpected")])
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
refine ::Regexp do
|
77
|
+
# @param value [Object] Value to be casted to String
|
78
|
+
# @return [ValueResult]
|
79
|
+
def cast(value)
|
80
|
+
return ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.regexp.cant_be_casted", value: value.inspect, default: :"_strong_csv.literal.regexp.cant_be_casted")]) if value.nil?
|
81
|
+
|
82
|
+
if self =~ value
|
83
|
+
ValueResult.new(value: value, original_value: value)
|
84
|
+
else
|
85
|
+
ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.regexp.unexpected", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.regexp.unexpected")])
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
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: [I18n.t("strong_csv.string.cant_be_casted", value: value.inspect, default: :"_strong_csv.string.cant_be_casted")]) if value.nil?
|
19
|
+
|
20
|
+
casted = String(value)
|
21
|
+
if @within && !@within.cover?(casted.size)
|
22
|
+
ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.string.out_of_range", value: value.inspect, range: @within.inspect, default: :"_strong_csv.string.out_of_range")])
|
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: [I18n.t("strong_csv.time.cant_be_casted", value: value.inspect, time_format: @format.inspect, default: :"_strong_csv.time.cant_be_casted")]) 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: [I18n.t("strong_csv.time.cant_be_casted", value: value.inspect, time_format: @format.inspect, default: :"_strong_csv.time.cant_be_casted")])
|
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
|
10
|
-
attr_reader :
|
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,
|
12
|
+
def initialize(original_value:, value: DEFAULT_VALUE, error_messages: nil)
|
13
13
|
@value = value
|
14
14
|
@original_value = original_value
|
15
|
-
@
|
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
|
-
@
|
25
|
+
@error_messages.nil?
|
26
26
|
end
|
27
27
|
end
|
28
28
|
end
|
data/lib/strong_csv/version.rb
CHANGED
data/lib/strong_csv.rb
CHANGED
@@ -2,12 +2,23 @@
|
|
2
2
|
|
3
3
|
require "csv"
|
4
4
|
require "forwardable"
|
5
|
+
require "set"
|
6
|
+
require "time"
|
7
|
+
require "i18n"
|
5
8
|
|
6
9
|
require_relative "strong_csv/version"
|
7
|
-
require_relative "strong_csv/
|
10
|
+
require_relative "strong_csv/i18n"
|
8
11
|
require_relative "strong_csv/value_result"
|
9
12
|
require_relative "strong_csv/types/base"
|
13
|
+
require_relative "strong_csv/types/boolean"
|
14
|
+
require_relative "strong_csv/types/float"
|
10
15
|
require_relative "strong_csv/types/integer"
|
16
|
+
require_relative "strong_csv/types/literal"
|
17
|
+
require_relative "strong_csv/types/optional"
|
18
|
+
require_relative "strong_csv/types/string"
|
19
|
+
require_relative "strong_csv/types/time"
|
20
|
+
require_relative "strong_csv/types/union"
|
21
|
+
require_relative "strong_csv/let"
|
11
22
|
require_relative "strong_csv/row"
|
12
23
|
|
13
24
|
# The top-level namespace for the strong_csv gem.
|
@@ -22,6 +33,8 @@ class StrongCSV
|
|
22
33
|
# @param csv [String, IO]
|
23
34
|
# @param options [Hash] CSV options for parsing.
|
24
35
|
def parse(csv, **options)
|
36
|
+
# NOTE: Some options are overridden here to ensure that StrongCSV can handle parsed values correctly.
|
37
|
+
options.delete(:nil_value)
|
25
38
|
options = options.merge(headers: @let.headers, header_converters: :symbol)
|
26
39
|
csv = CSV.new(csv, **options)
|
27
40
|
if block_given?
|
metadata
CHANGED
@@ -1,15 +1,35 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: strong_csv
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yutaka Kamei
|
8
8
|
autorequire:
|
9
|
-
bindir:
|
9
|
+
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
12
|
-
dependencies:
|
11
|
+
date: 2022-05-10 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: i18n
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.8.11
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '2'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 1.8.11
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '2'
|
13
33
|
description: strong_csv is a type checker for a CSV file. It lets developers declare
|
14
34
|
types for each column to ensure all cells are satisfied with desired types.
|
15
35
|
email:
|
@@ -21,10 +41,18 @@ files:
|
|
21
41
|
- LICENSE
|
22
42
|
- README.md
|
23
43
|
- lib/strong_csv.rb
|
44
|
+
- lib/strong_csv/i18n.rb
|
24
45
|
- lib/strong_csv/let.rb
|
25
46
|
- lib/strong_csv/row.rb
|
26
47
|
- lib/strong_csv/types/base.rb
|
48
|
+
- lib/strong_csv/types/boolean.rb
|
49
|
+
- lib/strong_csv/types/float.rb
|
27
50
|
- lib/strong_csv/types/integer.rb
|
51
|
+
- lib/strong_csv/types/literal.rb
|
52
|
+
- lib/strong_csv/types/optional.rb
|
53
|
+
- lib/strong_csv/types/string.rb
|
54
|
+
- lib/strong_csv/types/time.rb
|
55
|
+
- lib/strong_csv/types/union.rb
|
28
56
|
- lib/strong_csv/value_result.rb
|
29
57
|
- lib/strong_csv/version.rb
|
30
58
|
homepage: https://github.com/yykamei/strong_csv
|