schema-model 0.6.11 → 0.7.1

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