schema-model 0.6.10 → 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.
data/README.md CHANGED
@@ -1,163 +1,465 @@
1
- # schema
1
+ # Schema
2
2
 
3
- Fast and easy way to transform data into models for validation and type safety.
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
- [![Build Status](https://travis-ci.org/dougyouch/schema.svg?branch=master)](https://travis-ci.org/dougyouch/schema)
6
- [![Maintainability](https://api.codeclimate.com/v1/badges/c142d46a7a37d4a8c2e5/maintainability)](https://codeclimate.com/github/dougyouch/schema/maintainability)
7
- [![Test Coverage](https://api.codeclimate.com/v1/badges/c142d46a7a37d4a8c2e5/test_coverage)](https://codeclimate.com/github/dougyouch/schema/test_coverage)
5
+ [![CI](https://github.com/dougyouch/schema/actions/workflows/ci.yml/badge.svg)](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
6
+ [![codecov](https://codecov.io/gh/dougyouch/schema/graph/badge.svg)](https://codecov.io/gh/dougyouch/schema)
8
7
 
9
- Attributes of a model have a name and type. Any value passed in goes through a parser method. If the value can not be parsed successfully the error is added to parsing_errors.
8
+ ## Installation
10
9
 
11
- Associations are nested schema models. Each association can have its own set of attributes.
10
+ Add this line to your application's Gemfile:
12
11
 
13
- Dynamic associations are useful when creating custom logic around schema validation.
12
+ ```ruby
13
+ gem 'schema-model'
14
+ ```
14
15
 
15
- #### Example that show cases multiple features
16
+ And then execute:
16
17
 
17
- ###### spec/examples/company_schema.rb
18
- ```ruby
19
- # frozen_string_literal: true
18
+ ```bash
19
+ $ bundle install
20
+ ```
20
21
 
21
- # Example that show cases multiple features
22
- class CompanySchema
23
- # includes model, associations, parsers and active model validations
22
+ ## Quick Start
23
+
24
+ ```ruby
25
+ class UserSchema
24
26
  include Schema::All
25
27
 
26
- # add common attributes
27
- # attributes support additional names through the alias(es) option
28
- attribute :name, :string, alias: 'CompanyName'
29
- attribute :industry_type, :string, aliases: %w[IndustryType industry]
28
+ attribute :name, :string
29
+ attribute :age, :integer
30
+ attribute :email, :string
31
+ attribute :active, :boolean, default: false
32
+ attribute :tags, :array, separator: ',', data_type: :string
30
33
 
31
- # will take a string split on the separator and use the parse_<data_type> method on every element
32
- # basically take a list of comma separated numbers and create an array of integers
33
- # code snippet: str.split(',').map { |v| parse_integer(field_name, parsing_errors, v) }
34
- attribute :number_list, :array, separator: ',', data_type: :integer
34
+ validates :name, presence: true
35
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
36
+ end
35
37
 
36
- # creates a nested dynamic schema based on the industry_type which is part of the main company data
37
- industry_schema = has_one(:industry, external_type_field: :industry_type) do
38
- attribute :name, :string
38
+ user = UserSchema.from_hash(
39
+ name: 'John Doe',
40
+ age: '30',
41
+ email: 'john@example.com',
42
+ active: 'yes',
43
+ tags: 'ruby,rails,developer'
44
+ )
39
45
 
40
- validates :name, presence: true
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
+ ```
41
52
 
42
- add_type('tech') do
43
- attribute :custom_description, :string
44
- end
53
+ ## Data Types
45
54
 
46
- add_type('qsr') do
47
- attribute :number_of_locations, :integer
55
+ ### Basic Types
48
56
 
49
- # custom validation
50
- validates :number_of_locations, presence: true
51
- end
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
72
+ ```
73
+
74
+ ### Complex Types
75
+
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
85
+
86
+ ### Aliases
87
+
88
+ ```ruby
89
+ # Single alias
90
+ attribute :name, :string, alias: 'FullName'
91
+
92
+ # Multiple aliases
93
+ attribute :name, :string, aliases: [:full_name, :display_name]
94
+ ```
95
+
96
+ ### Default Values
97
+
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
105
+
106
+ Every attribute generates a `_was_set?` predicate method:
107
+
108
+ ```ruby
109
+ user = UserSchema.from_hash(name: 'John')
110
+ user.name_was_set? # => true
111
+ user.email_was_set? # => false (not provided)
112
+
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)
116
+ ```
117
+
118
+ ## Associations
119
+
120
+ ### Has One
121
+
122
+ ```ruby
123
+ class OrderSchema
124
+ include Schema::All
125
+
126
+ attribute :id, :integer
127
+
128
+ has_one :customer do
129
+ attribute :name, :string
130
+ attribute :email, :string
52
131
  end
132
+ end
53
133
 
54
- # create multiple dynamic location schemas based on the type field in the location data
55
- has_many(:locations, type_field: :type) do
56
- attribute :type, :string
57
- attribute :address, :string
58
- attribute :city, :string
59
- attribute :state, :string
60
- attribute :zip, :string
134
+ order = OrderSchema.from_hash(
135
+ id: 1,
136
+ customer: { name: 'Alice', email: 'alice@example.com' }
137
+ )
138
+ order.customer.name # => "Alice"
139
+ ```
61
140
 
62
- add_type('headquarters') do
63
- attribute :main_floor, :integer
141
+ ### Has Many
64
142
 
65
- validates :city, presence: true
66
- validates :main_floor, presence: true
67
- end
143
+ ```ruby
144
+ class OrderSchema
145
+ include Schema::All
68
146
 
69
- add_type('store_front') do
70
- attribute :main_entrance, :string
147
+ attribute :id, :integer
71
148
 
72
- validates :address, presence: true
73
- validates :main_entrance, presence: true
74
- end
149
+ has_many :items do
150
+ attribute :sku, :string
151
+ attribute :quantity, :integer
75
152
  end
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'
185
+ ```
186
+
187
+ ### Appending to Has Many
188
+
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
+ ```
76
194
 
77
- # create multiple dynamic employee schemas based on the type field in the employee data
78
- has_many(:employees, type_field: :type) do
79
- attribute :type, :integer
195
+ ## Dynamic Types
196
+
197
+ Create different model structures based on a type field:
198
+
199
+ ```ruby
200
+ class CompanySchema
201
+ include Schema::All
202
+
203
+ has_many :employees, type_field: :type do
204
+ attribute :type, :string
80
205
  attribute :name, :string
81
- attribute :start_date, :date
82
- add_type(1) do # worker
83
- attribute :manager_name, :string
206
+
207
+ add_type('engineer') do
208
+ attribute :programming_languages, :array, separator: ','
84
209
  end
85
- add_type(2) do # manager
86
- attribute :rank, :float
210
+
211
+ add_type('manager') do
212
+ attribute :department, :string
213
+ attribute :team_size, :integer
87
214
  end
88
- # if no or an invalid type is specified, create a default employee schema object
89
- # useful for communicating errors in an API
90
- default_type
91
215
 
92
- # dynamic_type_names returns all the types used, except for :default
93
- validates :type, inclusion: { in: dynamic_type_names }
216
+ default_type do
217
+ # Fallback for unknown types
218
+ end
94
219
  end
220
+ end
95
221
 
96
- has_many(:admins, from: :hash, hash_key_field: :username) do
97
- attribute :username, :string
98
- attribute :email, :string
99
- attribute :name, :string
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
+ )
100
228
 
101
- validates :username, presence: true
102
- validates :email, presence: true
103
- end
229
+ company.employees[0].programming_languages # => ["ruby", "python"]
230
+ company.employees[1].team_size # => 5
231
+ ```
232
+
233
+ ### Dynamic Type Options
234
+
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
255
+
256
+ ```ruby
257
+ class UserSchema
258
+ include Schema::All
259
+
260
+ attribute :name, :string
261
+ attribute :email, :string
262
+ attribute :age, :integer
104
263
 
105
264
  validates :name, presence: true
106
- validates :industry_type, inclusion: { in: industry_schema.dynamic_type_names }
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')
107
274
 
108
- # use the schema validator
109
- validates :industry, presence: true, schema: true
110
- validates :locations, presence: true, schema: true
111
- validates :employees, presence: true, schema: true
112
- validates :admins, presence: true, schema: true
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
113
299
  end
114
300
  ```
115
301
 
116
- ###### spec/examples/company_schema.json
117
- ```javascript
118
- {
119
- "CompanyName": "Good Burger",
120
- "IndustryType": "qsr",
121
- "industry": {
122
- "name": "Food & Beverage",
123
- "number_of_locations": 2
124
- },
125
- "locations": [
126
- {
127
- "type": "headquarters",
128
- "city": "Boston",
129
- "main_floor": 5
130
- },
131
- {
132
- "type": "store_front",
133
- "address": "1st Ave",
134
- "zip": "02211",
135
- "main_entrance": "side door"
136
- }
137
- ],
138
- "employees": [
139
- {
140
- "type": 2,
141
- "name": "Queen Bee",
142
- "start_date": "2016-01-09",
143
- "rank": "0.9"
144
- },
145
- {
146
- "type": 1,
147
- "name": "Worker Bee",
148
- "start_date": "2018-05-10",
149
- "manager_name": "Queen Bee"
150
- }
151
- ],
152
- "admins": {
153
- "captain": {
154
- "email": "captain@example.com",
155
- "name": "Captain Kurk"
156
- },
157
- "joe": {
158
- "email": "joe.smith@example.com",
159
- "name": "Joe Smith"
160
- }
161
- }
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"]
309
+
310
+ # Disable this behavior
311
+ UserSchema.capture_unknown_attributes = false
312
+ ```
313
+
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 }
324
+
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
331
+
332
+ Prevent certain fields from being set by user input:
333
+
334
+ ```ruby
335
+ user_data = {
336
+ id: 123,
337
+ name: 'John Doe',
338
+ created_at: '2024-01-01'
162
339
  }
340
+
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)
395
+
396
+ parser.each do |user|
397
+ puts user.name
398
+ end
399
+ ```
400
+
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) # => []
163
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
+ ```
454
+
455
+ ## Contributing
456
+
457
+ 1. Fork the repository
458
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
459
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
460
+ 4. Push to the branch (`git push origin my-new-feature`)
461
+ 5. Create a new Pull Request
462
+
463
+ ## License
464
+
465
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -11,13 +11,13 @@ module Schema
11
11
  end
12
12
 
13
13
  def valid_model!
14
- unless valid?
15
- raise ValidationException.new(
16
- "invalid values for attributes #{errors.map(&:attribute).join(', ')}",
17
- self,
18
- errors
19
- )
20
- end
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
- unless parsed?
40
- raise ParsingException.new(
41
- "schema parsing failed for attributes #{parsing_errors.errors.map(&:attribute).join(', ')}",
42
- self,
43
- parsing_errors
44
- )
45
- end
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
@@ -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 || (field_options[:aliases] && field_options[:aliases].first),
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 + 'X' + field_name.to_s if 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