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