schema-model 0.6.11 → 0.7.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/.github/workflows/ci.yml +53 -0
- data/.rubocop.yml +70 -8
- data/ARCHITECTURE.md +135 -0
- data/CLAUDE.md +74 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +39 -36
- data/README.md +360 -85
- data/lib/schema/active_model_validations.rb +14 -14
- data/lib/schema/array_headers.rb +6 -4
- data/lib/schema/arrays.rb +11 -13
- data/lib/schema/associations/dynamic_types.rb +3 -5
- data/lib/schema/associations/has_many.rb +2 -4
- data/lib/schema/associations/has_one.rb +3 -5
- data/lib/schema/associations/schema_creator.rb +2 -2
- data/lib/schema/csv_parser.rb +1 -1
- data/lib/schema/model.rb +26 -21
- data/lib/schema/parsers/array.rb +2 -2
- data/lib/schema/parsers/common.rb +6 -8
- data/lib/schema/parsers/hash.rb +1 -1
- data/lib/schema/parsers/json.rb +1 -1
- data/lib/schema/parsing_errors.rb +1 -1
- data/lib/schema/utils.rb +5 -5
- data/lib/schema-model.rb +1 -0
- data/schema-model.gemspec +6 -4
- metadata +10 -11
- data/.cursor-rules +0 -39
- data/.travis.yml +0 -14
- data/Rakefile +0 -11
- data/docs/ARCHITECTURE.md +0 -171
- data/script/bundle_install_all_versions +0 -8
- data/script/rspec_all_versions +0 -5
data/README.md
CHANGED
|
@@ -2,19 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A powerful Ruby gem for data transformation, validation, and type safety. Schema provides a flexible and intuitive way to define data models with support for complex nested structures, dynamic associations, and robust validation.
|
|
4
4
|
|
|
5
|
-
[](https://codeclimate.com/github/dougyouch/schema/test_coverage)
|
|
8
|
-
|
|
9
|
-
## Features
|
|
10
|
-
|
|
11
|
-
- **Type Safety**: Strong typing with automatic parsing and validation
|
|
12
|
-
- **Flexible Attributes**: Support for aliases and custom data types
|
|
13
|
-
- **Nested Models**: Complex data structures with nested associations
|
|
14
|
-
- **Dynamic Associations**: Runtime type-based model creation
|
|
15
|
-
- **ActiveModel Integration**: Seamless integration with ActiveModel validations
|
|
16
|
-
- **Error Handling**: Comprehensive error collection and reporting
|
|
17
|
-
- **CSV Support**: Built-in CSV parsing capabilities
|
|
5
|
+
[](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
|
|
6
|
+
[](https://codecov.io/gh/dougyouch/schema)
|
|
18
7
|
|
|
19
8
|
## Installation
|
|
20
9
|
|
|
@@ -32,8 +21,6 @@ $ bundle install
|
|
|
32
21
|
|
|
33
22
|
## Quick Start
|
|
34
23
|
|
|
35
|
-
Here's a simple example to get you started:
|
|
36
|
-
|
|
37
24
|
```ruby
|
|
38
25
|
class UserSchema
|
|
39
26
|
include Schema::All
|
|
@@ -41,140 +28,429 @@ class UserSchema
|
|
|
41
28
|
attribute :name, :string
|
|
42
29
|
attribute :age, :integer
|
|
43
30
|
attribute :email, :string
|
|
31
|
+
attribute :active, :boolean, default: false
|
|
44
32
|
attribute :tags, :array, separator: ',', data_type: :string
|
|
45
33
|
|
|
46
34
|
validates :name, presence: true
|
|
47
35
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
48
36
|
end
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
user_data = {
|
|
38
|
+
user = UserSchema.from_hash(
|
|
52
39
|
name: 'John Doe',
|
|
53
40
|
age: '30',
|
|
54
41
|
email: 'john@example.com',
|
|
42
|
+
active: 'yes',
|
|
55
43
|
tags: 'ruby,rails,developer'
|
|
56
|
-
|
|
44
|
+
)
|
|
57
45
|
|
|
58
|
-
user
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
user.valid? # => true
|
|
47
|
+
user.name # => "John Doe"
|
|
48
|
+
user.age # => 30 (parsed to integer)
|
|
49
|
+
user.active # => true (parsed from "yes")
|
|
50
|
+
user.tags # => ["ruby", "rails", "developer"]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Data Types
|
|
54
|
+
|
|
55
|
+
### Basic Types
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
attribute :name, :string # String values
|
|
59
|
+
attribute :count, :integer # Integer values (parses "123" to 123)
|
|
60
|
+
attribute :price, :float # Float values (parses "9.99" to 9.99)
|
|
61
|
+
attribute :active, :boolean # Boolean (accepts: 1, t, true, on, y, yes)
|
|
62
|
+
attribute :notes, :string_or_nil # String, but returns nil if empty
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Date and Time Types
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
attribute :created_at, :time # ISO 8601 format (Time.xmlschema)
|
|
69
|
+
attribute :birth_date, :date # Date.parse format
|
|
70
|
+
attribute :us_date, :american_date # MM/DD/YYYY format
|
|
71
|
+
attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
|
|
64
72
|
```
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
### Complex Types
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
```ruby
|
|
77
|
+
attribute :tags, :array # Array of values
|
|
78
|
+
attribute :tags, :array, separator: ',' # Parse "a,b,c" into ["a","b","c"]
|
|
79
|
+
attribute :tags, :array, separator: ',', data_type: :integer # Parse and convert elements
|
|
80
|
+
attribute :metadata, :hash # Hash/dictionary values
|
|
81
|
+
attribute :config, :json # Parse JSON strings
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Attribute Options
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
- A name
|
|
72
|
-
- A type
|
|
73
|
-
- Optional aliases
|
|
74
|
-
- Custom parsing rules
|
|
86
|
+
### Aliases
|
|
75
87
|
|
|
76
88
|
```ruby
|
|
89
|
+
# Single alias
|
|
77
90
|
attribute :name, :string, alias: 'FullName'
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
|
|
92
|
+
# Multiple aliases
|
|
93
|
+
attribute :name, :string, aliases: [:full_name, :display_name]
|
|
80
94
|
```
|
|
81
95
|
|
|
82
|
-
###
|
|
96
|
+
### Default Values
|
|
83
97
|
|
|
84
|
-
|
|
98
|
+
```ruby
|
|
99
|
+
attribute :status, :string, default: 'pending'
|
|
100
|
+
attribute :count, :integer, default: 0
|
|
101
|
+
attribute :tags, :array, default: []
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Checking If Attribute Was Set
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
2. **Has Many**: Multiple nested models
|
|
88
|
-
3. **Dynamic Associations**: Type-based model creation
|
|
106
|
+
Every attribute generates a `_was_set?` predicate method:
|
|
89
107
|
|
|
90
108
|
```ruby
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
end
|
|
109
|
+
user = UserSchema.from_hash(name: 'John')
|
|
110
|
+
user.name_was_set? # => true
|
|
111
|
+
user.email_was_set? # => false (not provided)
|
|
95
112
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
end
|
|
113
|
+
# Useful for distinguishing "not provided" vs "provided as nil"
|
|
114
|
+
user = UserSchema.from_hash(name: nil)
|
|
115
|
+
user.name_was_set? # => true (explicitly set to nil)
|
|
100
116
|
```
|
|
101
117
|
|
|
102
|
-
|
|
118
|
+
## Associations
|
|
103
119
|
|
|
104
|
-
|
|
120
|
+
### Has One
|
|
105
121
|
|
|
106
122
|
```ruby
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
class OrderSchema
|
|
124
|
+
include Schema::All
|
|
125
|
+
|
|
126
|
+
attribute :id, :integer
|
|
110
127
|
|
|
111
|
-
|
|
112
|
-
attribute :
|
|
128
|
+
has_one :customer do
|
|
129
|
+
attribute :name, :string
|
|
130
|
+
attribute :email, :string
|
|
113
131
|
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
order = OrderSchema.from_hash(
|
|
135
|
+
id: 1,
|
|
136
|
+
customer: { name: 'Alice', email: 'alice@example.com' }
|
|
137
|
+
)
|
|
138
|
+
order.customer.name # => "Alice"
|
|
139
|
+
```
|
|
114
140
|
|
|
115
|
-
|
|
116
|
-
|
|
141
|
+
### Has Many
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class OrderSchema
|
|
145
|
+
include Schema::All
|
|
146
|
+
|
|
147
|
+
attribute :id, :integer
|
|
148
|
+
|
|
149
|
+
has_many :items do
|
|
150
|
+
attribute :sku, :string
|
|
151
|
+
attribute :quantity, :integer
|
|
117
152
|
end
|
|
118
153
|
end
|
|
154
|
+
|
|
155
|
+
order = OrderSchema.from_hash(
|
|
156
|
+
id: 1,
|
|
157
|
+
items: [
|
|
158
|
+
{ sku: 'ABC', quantity: 2 },
|
|
159
|
+
{ sku: 'XYZ', quantity: 1 }
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
order.items.length # => 2
|
|
163
|
+
order.items.first.sku # => "ABC"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Association Options
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# Default values - creates empty model/array if not provided
|
|
170
|
+
has_one :profile, default: true
|
|
171
|
+
has_many :tags, default: true
|
|
172
|
+
|
|
173
|
+
# Reuse existing schema class
|
|
174
|
+
has_one :shipping_address, base_class: AddressSchema
|
|
175
|
+
has_one :billing_address, base_class: AddressSchema
|
|
176
|
+
|
|
177
|
+
# Has many from hash (keyed by field)
|
|
178
|
+
has_many :items, from: :hash, hash_key_field: :id do
|
|
179
|
+
attribute :id, :string
|
|
180
|
+
attribute :name, :string
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Input: { items: { 'abc' => { name: 'Item 1' }, 'xyz' => { name: 'Item 2' } } }
|
|
184
|
+
# Result: items[0].id => 'abc', items[1].id => 'xyz'
|
|
119
185
|
```
|
|
120
186
|
|
|
121
|
-
|
|
187
|
+
### Appending to Has Many
|
|
122
188
|
|
|
123
|
-
|
|
189
|
+
```ruby
|
|
190
|
+
order = OrderSchema.from_hash(id: 1, items: [])
|
|
191
|
+
order.append_to_items(sku: 'NEW', quantity: 5)
|
|
192
|
+
order.items.length # => 1
|
|
193
|
+
```
|
|
124
194
|
|
|
125
|
-
|
|
195
|
+
## Dynamic Types
|
|
196
|
+
|
|
197
|
+
Create different model structures based on a type field:
|
|
126
198
|
|
|
127
199
|
```ruby
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
200
|
+
class CompanySchema
|
|
201
|
+
include Schema::All
|
|
202
|
+
|
|
203
|
+
has_many :employees, type_field: :type do
|
|
204
|
+
attribute :type, :string
|
|
205
|
+
attribute :name, :string
|
|
206
|
+
|
|
207
|
+
add_type('engineer') do
|
|
208
|
+
attribute :programming_languages, :array, separator: ','
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
add_type('manager') do
|
|
212
|
+
attribute :department, :string
|
|
213
|
+
attribute :team_size, :integer
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
default_type do
|
|
217
|
+
# Fallback for unknown types
|
|
218
|
+
end
|
|
131
219
|
end
|
|
132
220
|
end
|
|
221
|
+
|
|
222
|
+
company = CompanySchema.from_hash(
|
|
223
|
+
employees: [
|
|
224
|
+
{ type: 'engineer', name: 'Alice', programming_languages: 'ruby,python' },
|
|
225
|
+
{ type: 'manager', name: 'Bob', department: 'Engineering', team_size: 5 }
|
|
226
|
+
]
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
company.employees[0].programming_languages # => ["ruby", "python"]
|
|
230
|
+
company.employees[1].team_size # => 5
|
|
133
231
|
```
|
|
134
232
|
|
|
135
|
-
###
|
|
233
|
+
### Dynamic Type Options
|
|
136
234
|
|
|
137
|
-
|
|
235
|
+
```ruby
|
|
236
|
+
# Type field within nested data (default)
|
|
237
|
+
has_many :items, type_field: :kind do
|
|
238
|
+
# looks for :kind in each item's data
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Type determined by parent field
|
|
242
|
+
has_one :details, external_type_field: :category do
|
|
243
|
+
# uses parent's :category field to determine type
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Case-insensitive type matching
|
|
247
|
+
has_many :items, type_field: :type, type_ignorecase: true do
|
|
248
|
+
add_type('widget') { } # matches "Widget", "WIDGET", etc.
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Validation and Error Handling
|
|
253
|
+
|
|
254
|
+
### ActiveModel Validations
|
|
138
255
|
|
|
139
256
|
```ruby
|
|
140
|
-
class
|
|
141
|
-
include Schema::
|
|
142
|
-
|
|
257
|
+
class UserSchema
|
|
258
|
+
include Schema::All
|
|
259
|
+
|
|
143
260
|
attribute :name, :string
|
|
144
261
|
attribute :email, :string
|
|
262
|
+
attribute :age, :integer
|
|
263
|
+
|
|
264
|
+
validates :name, presence: true
|
|
265
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
266
|
+
validates :age, numericality: { greater_than: 0 }, allow_nil: true
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Parsing Errors vs Validation Errors
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
user = UserSchema.from_hash(name: 'John', age: 'not-a-number')
|
|
274
|
+
|
|
275
|
+
# Parsing errors (type conversion failures)
|
|
276
|
+
user.parsing_errors.empty? # => false
|
|
277
|
+
user.parsed? # => false
|
|
278
|
+
|
|
279
|
+
# Validation errors (business rules)
|
|
280
|
+
user.valid? # => false (also runs validations)
|
|
281
|
+
user.errors.full_messages # => ["Age is invalid", ...]
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Raising Exceptions
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
user = UserSchema.from_hash(age: 'invalid')
|
|
288
|
+
|
|
289
|
+
user.parsed! # raises Schema::ParsingException if parsing errors
|
|
290
|
+
user.valid_model! # raises Schema::ValidationException if validation errors
|
|
291
|
+
user.valid! # raises either (checks both)
|
|
292
|
+
|
|
293
|
+
# Exception includes the model and errors
|
|
294
|
+
begin
|
|
295
|
+
user.valid!
|
|
296
|
+
rescue Schema::ParsingException => e
|
|
297
|
+
e.schema # => the model instance
|
|
298
|
+
e.errors # => the errors object
|
|
145
299
|
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Unknown Attributes
|
|
303
|
+
|
|
304
|
+
By default, unknown attributes are captured as parsing errors:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
|
|
308
|
+
user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
|
|
146
309
|
|
|
147
|
-
|
|
310
|
+
# Disable this behavior
|
|
311
|
+
UserSchema.capture_unknown_attributes = false
|
|
148
312
|
```
|
|
149
313
|
|
|
150
|
-
##
|
|
314
|
+
## Serialization
|
|
315
|
+
|
|
316
|
+
### to_hash / as_json
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
user = UserSchema.from_hash(name: 'John', email: nil)
|
|
320
|
+
|
|
321
|
+
user.to_hash # => { name: "John", email: nil }
|
|
322
|
+
user.as_json # => { name: "John" } (excludes nils)
|
|
323
|
+
user.as_json(include_nils: true) # => { name: "John", email: nil }
|
|
151
324
|
|
|
152
|
-
|
|
325
|
+
# Filter fields
|
|
326
|
+
user.as_json(select_filter: ->(name, value, opts) { name == :name })
|
|
327
|
+
user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Protecting Fields with skip_fields
|
|
153
331
|
|
|
154
|
-
|
|
332
|
+
Prevent certain fields from being set by user input:
|
|
155
333
|
|
|
156
334
|
```ruby
|
|
157
335
|
user_data = {
|
|
158
|
-
id: 123,
|
|
336
|
+
id: 123,
|
|
159
337
|
name: 'John Doe',
|
|
160
|
-
|
|
161
|
-
created_at: '2024-06-01T12:00:00Z', # Should be managed by DB
|
|
162
|
-
updated_at: '2024-06-01T12:00:00Z' # Should be managed by DB
|
|
338
|
+
created_at: '2024-01-01'
|
|
163
339
|
}
|
|
164
340
|
|
|
165
|
-
# Skip
|
|
166
|
-
user = UserSchema.from_hash(user_data, [:id, :created_at
|
|
341
|
+
# Skip database-managed fields
|
|
342
|
+
user = UserSchema.from_hash(user_data, [:id, :created_at])
|
|
343
|
+
|
|
344
|
+
user.id # => nil (not set)
|
|
345
|
+
user.name # => "John Doe"
|
|
346
|
+
user.created_at # => nil (not set)
|
|
347
|
+
|
|
348
|
+
# Nested skip_fields for associations
|
|
349
|
+
order = OrderSchema.from_hash(data, [:id, { items: [:id] }])
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Array and CSV Support
|
|
353
|
+
|
|
354
|
+
### Schema::Arrays Module
|
|
355
|
+
|
|
356
|
+
Convert models to/from flat arrays (useful for CSV/spreadsheet data):
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
class UserSchema
|
|
360
|
+
include Schema::All
|
|
361
|
+
schema_include Schema::Arrays
|
|
362
|
+
|
|
363
|
+
attribute :name, :string
|
|
364
|
+
attribute :email, :string
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Get headers
|
|
368
|
+
UserSchema.to_headers # => ["name", "email"]
|
|
369
|
+
|
|
370
|
+
# Convert to array
|
|
371
|
+
user = UserSchema.from_hash(name: 'John', email: 'john@example.com')
|
|
372
|
+
user.to_a # => ["John", "john@example.com"]
|
|
373
|
+
|
|
374
|
+
# Create from array
|
|
375
|
+
headers = ['name', 'email']
|
|
376
|
+
mapped = UserSchema.map_headers_to_attributes(headers)
|
|
377
|
+
user = UserSchema.from_array(['Jane', 'jane@example.com'], mapped)
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Schema::CSVParser Module
|
|
381
|
+
|
|
382
|
+
Parse CSV data directly into models:
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
class UserCSVSchema
|
|
386
|
+
include Schema::Model
|
|
387
|
+
include Schema::CSVParser
|
|
388
|
+
|
|
389
|
+
attribute :name, :string
|
|
390
|
+
attribute :email, :string
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
csv_data = CSV.parse("name,email\nJohn,john@example.com", headers: true)
|
|
394
|
+
parser = Schema::CSVParser.new(csv_data, UserCSVSchema)
|
|
167
395
|
|
|
168
|
-
|
|
169
|
-
puts user.
|
|
170
|
-
|
|
171
|
-
puts user.name # => 'John Doe'
|
|
396
|
+
parser.each do |user|
|
|
397
|
+
puts user.name
|
|
398
|
+
end
|
|
172
399
|
```
|
|
173
400
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
401
|
+
### Schema::ArrayHeaders Module
|
|
402
|
+
|
|
403
|
+
Map CSV/array headers to schema attributes:
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
class UserSchema
|
|
407
|
+
include Schema::All
|
|
408
|
+
schema_include Schema::ArrayHeaders
|
|
409
|
+
|
|
410
|
+
attribute :name, :string, alias: 'FullName'
|
|
411
|
+
attribute :email, :string
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
headers = ['FullName', 'email', 'unknown_column']
|
|
415
|
+
mapped = UserSchema.map_headers_to_attributes(headers)
|
|
416
|
+
# => { name: { index: 0 }, email: { index: 1 } }
|
|
417
|
+
|
|
418
|
+
UserSchema.get_mapped_field_names(mapped) # => ["name", "email"]
|
|
419
|
+
UserSchema.get_unmapped_field_names(mapped) # => []
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Extending Schemas
|
|
423
|
+
|
|
424
|
+
### schema_include
|
|
425
|
+
|
|
426
|
+
Add modules to a schema and all its nested associations:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
class OrderSchema
|
|
430
|
+
include Schema::All
|
|
431
|
+
|
|
432
|
+
has_many :items do
|
|
433
|
+
attribute :name, :string
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Add Arrays support to OrderSchema and OrderSchema::SchemaHasManyItems
|
|
438
|
+
OrderSchema.schema_include Schema::Arrays
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## CLI Tools
|
|
442
|
+
|
|
443
|
+
### schema-json2csv
|
|
444
|
+
|
|
445
|
+
Convert JSON data to CSV using a schema:
|
|
446
|
+
|
|
447
|
+
```bash
|
|
448
|
+
# Basic usage
|
|
449
|
+
schema-json2csv --require ./my_schema.rb --schema MySchema --json data.json --csv output.csv
|
|
450
|
+
|
|
451
|
+
# From stdin
|
|
452
|
+
cat data.json | schema-json2csv --require ./my_schema.rb --schema MySchema - --csv output.csv
|
|
453
|
+
```
|
|
178
454
|
|
|
179
455
|
## Contributing
|
|
180
456
|
|
|
@@ -187,4 +463,3 @@ puts user.name # => 'John Doe'
|
|
|
187
463
|
## License
|
|
188
464
|
|
|
189
465
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
190
|
-
|
|
@@ -11,13 +11,13 @@ module Schema
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def valid_model!
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
return if valid?
|
|
15
|
+
|
|
16
|
+
raise ValidationException.new(
|
|
17
|
+
"invalid values for attributes #{errors.map(&:attribute).join(', ')}",
|
|
18
|
+
self,
|
|
19
|
+
errors
|
|
20
|
+
)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def valid!
|
|
@@ -36,13 +36,13 @@ module Schema
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def parsed!
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
return if parsed?
|
|
40
|
+
|
|
41
|
+
raise ParsingException.new(
|
|
42
|
+
"schema parsing failed for attributes #{parsing_errors.errors.map(&:attribute).join(', ')}",
|
|
43
|
+
self,
|
|
44
|
+
parsing_errors
|
|
45
|
+
)
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
data/lib/schema/array_headers.rb
CHANGED
|
@@ -41,13 +41,15 @@ module Schema
|
|
|
41
41
|
fields
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
MAX_ARRAY_INDEX = 10_000
|
|
45
|
+
|
|
44
46
|
private
|
|
45
47
|
|
|
46
48
|
def get_model_field_names(field_name, field_options, mapped_headers, header_prefix, mapped)
|
|
47
49
|
mapped_model = mapped_headers[field_name] || {}
|
|
48
50
|
const_get(field_options[:class_name]).get_field_names(
|
|
49
51
|
mapped_model,
|
|
50
|
-
header_prefix ||
|
|
52
|
+
header_prefix || field_options[:aliases]&.first,
|
|
51
53
|
mapped
|
|
52
54
|
)
|
|
53
55
|
end
|
|
@@ -62,7 +64,7 @@ module Schema
|
|
|
62
64
|
|
|
63
65
|
def generate_field_name(field_name, field_options, header_prefix)
|
|
64
66
|
field_name = field_options[:aliases].first if field_options[:aliases]
|
|
65
|
-
field_name = header_prefix
|
|
67
|
+
field_name = "#{header_prefix}X#{field_name}" if header_prefix
|
|
66
68
|
field_name.to_s
|
|
67
69
|
end
|
|
68
70
|
|
|
@@ -114,9 +116,9 @@ module Schema
|
|
|
114
116
|
cnt = field_options[:starting_index] || 1
|
|
115
117
|
indexes = []
|
|
116
118
|
# finding all headers that look like Company1Name through CompanyXName
|
|
117
|
-
while (index = headers.index(header_prefix + cnt.to_s + field_options[:key]))
|
|
118
|
-
cnt += 1
|
|
119
|
+
while cnt <= MAX_ARRAY_INDEX && (index = headers.index(header_prefix + cnt.to_s + field_options[:key]))
|
|
119
120
|
indexes << index
|
|
121
|
+
cnt += 1
|
|
120
122
|
end
|
|
121
123
|
indexes
|
|
122
124
|
end
|
data/lib/schema/arrays.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Schema
|
|
|
15
15
|
|
|
16
16
|
def to_empty_array
|
|
17
17
|
data = []
|
|
18
|
-
schema.
|
|
18
|
+
schema.each_value do |field_options|
|
|
19
19
|
next if field_options[:alias_of]
|
|
20
20
|
|
|
21
21
|
data <<
|
|
@@ -24,8 +24,6 @@ module Schema
|
|
|
24
24
|
const_get(field_options[:class_name]).to_empty_array
|
|
25
25
|
when :has_many
|
|
26
26
|
field_options[:size].times.map { const_get(field_options[:class_name]).to_empty_array }
|
|
27
|
-
else
|
|
28
|
-
nil
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
data
|
|
@@ -33,16 +31,16 @@ module Schema
|
|
|
33
31
|
|
|
34
32
|
def to_headers(prefix = nil)
|
|
35
33
|
headers = []
|
|
36
|
-
schema.
|
|
34
|
+
schema.each_value do |field_options|
|
|
37
35
|
next if field_options[:alias_of]
|
|
38
36
|
|
|
39
37
|
headers <<
|
|
40
38
|
case field_options[:type]
|
|
41
39
|
when :has_one
|
|
42
|
-
const_get(field_options[:class_name]).to_headers(prefix
|
|
40
|
+
const_get(field_options[:class_name]).to_headers("#{prefix}#{field_options[:key]}.")
|
|
43
41
|
when :has_many
|
|
44
42
|
field_options[:size].times.map do |i|
|
|
45
|
-
const_get(field_options[:class_name]).to_headers(prefix.to_s + field_options[:key] + "[#{i +1}].")
|
|
43
|
+
const_get(field_options[:class_name]).to_headers(prefix.to_s + field_options[:key] + "[#{i + 1}].")
|
|
46
44
|
end
|
|
47
45
|
else
|
|
48
46
|
prefix.to_s + field_options[:key]
|
|
@@ -54,7 +52,7 @@ module Schema
|
|
|
54
52
|
|
|
55
53
|
def to_a
|
|
56
54
|
data = []
|
|
57
|
-
self.class.schema.
|
|
55
|
+
self.class.schema.each_value do |field_options|
|
|
58
56
|
next if field_options[:alias_of]
|
|
59
57
|
|
|
60
58
|
value = public_send(field_options[:getter])
|
|
@@ -76,7 +74,7 @@ module Schema
|
|
|
76
74
|
end
|
|
77
75
|
|
|
78
76
|
def update_attributes_with_array(array, mapped_headers, offset = nil)
|
|
79
|
-
self.class.schema.
|
|
77
|
+
self.class.schema.each_value do |field_options|
|
|
80
78
|
next unless (mapped_field = mapped_headers[field_options[:name]])
|
|
81
79
|
|
|
82
80
|
if offset
|
|
@@ -103,7 +101,7 @@ module Schema
|
|
|
103
101
|
end
|
|
104
102
|
|
|
105
103
|
def update_nested_has_one_associations_from_array(array, mapped_headers, current_offset = nil)
|
|
106
|
-
self.class.schema.
|
|
104
|
+
self.class.schema.each_value do |field_options|
|
|
107
105
|
next unless field_options[:type] == :has_one
|
|
108
106
|
next unless (mapped_model = mapped_headers[field_options[:name]])
|
|
109
107
|
|
|
@@ -115,7 +113,7 @@ module Schema
|
|
|
115
113
|
end
|
|
116
114
|
|
|
117
115
|
def update_nested_has_many_associations_from_array(array, mapped_headers)
|
|
118
|
-
self.class.schema.
|
|
116
|
+
self.class.schema.each_value do |field_options|
|
|
119
117
|
next unless field_options[:type] == :has_many
|
|
120
118
|
next unless (mapped_model = mapped_headers[field_options[:name]])
|
|
121
119
|
|
|
@@ -136,10 +134,10 @@ module Schema
|
|
|
136
134
|
|
|
137
135
|
def largest_number_of_indexes_from_map(mapped_model)
|
|
138
136
|
size = 0
|
|
139
|
-
mapped_model.
|
|
137
|
+
mapped_model.each_value do |info|
|
|
140
138
|
if info[:indexes]
|
|
141
|
-
size = info[:indexes].size if info[:indexes]
|
|
142
|
-
|
|
139
|
+
size = info[:indexes].size if info[:indexes].size > size
|
|
140
|
+
elsif info.is_a?(Hash)
|
|
143
141
|
new_size = largest_number_of_indexes_from_map(info)
|
|
144
142
|
size = new_size if new_size > size
|
|
145
143
|
end
|