rasti-model 2.0.1 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd4831ac82eadc7515e4103a10fe010346ec8719d16db0d30103a713b5cd15ef
4
- data.tar.gz: b363b3bb323dbb5d1eec404e6adb7c3eef6d6673b0a19474d467a40551f280f8
3
+ metadata.gz: f31f21d81848581016911f75ce3b781e7603ef7bb39c3ea52ba860c7d12a8f01
4
+ data.tar.gz: e0ce2ae0c8303f5d2f87b4b0b3d4437f9ff8117eac6e9f6e44c71e3e5d16c190
5
5
  SHA512:
6
- metadata.gz: 2e0659afca7e7f8a38731ea5901029f4f346895f357430970eba5f251745e2ceaa765ac9731e47c03a7504e2d66d9fccf57e08ea7f58230fabab8c86ca3e2962
7
- data.tar.gz: aabf1480e4bd1b45c6cde754775714047beff31d89a571fb1b617f4aa4f174a65b922fecd8ddcd90ee3b2acaedf5e3aae70a63d33ddfdcbdde41bbe35fcb8fc9
6
+ metadata.gz: d6ffb39110babca82a4a714f2a08ad3fcc75028ebe083fd0b1386441a98a53e0d0d98d0e0b519eef91bc4e1f0da7e87b78dfae14cff8c41d6eae6db630ef6ac1
7
+ data.tar.gz: e5b31f2fbc7282d7ed3fb7757a4a8fb3e8b33f27e50c2c4d9c523bfa14b586fc5d65b53a8cc9f6befb568562295c704206c81426e1f5d882b04cc79cba25e9b1
data/README.md CHANGED
@@ -3,7 +3,6 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/rasti-model.svg)](https://rubygems.org/gems/rasti-model)
4
4
  [![CI](https://github.com/gabynaiman/rasti-model/actions/workflows/ci.yml/badge.svg)](https://github.com/gabynaiman/rasti-model/actions/workflows/ci.yml)
5
5
  [![Coverage Status](https://coveralls.io/repos/github/gabynaiman/rasti-model/badge.svg?branch=master)](https://coveralls.io/github/gabynaiman/rasti-model?branch=master)
6
- [![Code Climate](https://codeclimate.com/github/gabynaiman/rasti-model.svg)](https://codeclimate.com/github/gabynaiman/rasti-model)
7
6
 
8
7
  Domain models with typed attributes
9
8
 
@@ -35,6 +34,9 @@ end
35
34
  point = Point.new x: 1, y: 2
36
35
  point.x # => 1
37
36
  point.y # => 2
37
+
38
+ # Unexpected attributes
39
+ Point.new z: 3 # => Rasti::Model::UnexpectedAttributesError: Unexpected attributes: z
38
40
  ```
39
41
 
40
42
  ### Typed models
@@ -77,6 +79,54 @@ country.name # => 'Argentina'
77
79
  country.cities # => [City[name: "Buenos Aires"], City[name: "Córdoba"], City[name: "Rosario"]]
78
80
 
79
81
  country.to_h # => attributes
82
+
83
+ # Attribute filtering
84
+ country.to_h(only: [:name]) # => {name: "Argentina"}
85
+ country.to_h(except: [:cities]) # => {name: "Argentina"}
86
+ ```
87
+
88
+ ### Default values
89
+ ```ruby
90
+ class User < Rasti::Model
91
+ attribute :name, T::String
92
+ attribute :admin, T::Boolean, default: false
93
+ attribute :created_at, T::Time, default: ->(m) { Time.now }
94
+ end
95
+
96
+ user = User.new name: 'John'
97
+ user.admin # => false
98
+ user.created_at # => 2026-01-02 23:19:15 -0300
99
+ ```
100
+
101
+ ### Merging models
102
+ ```ruby
103
+ point_1 = Point.new x: 1, y: 2
104
+ point_2 = point_1.merge x: 10
105
+ point_2.to_h # => {x: 10, y: 2}
106
+ ```
107
+
108
+ ### Custom attribute options
109
+ You can add custom metadata to attributes that can be used later (e.g., for UI generation):
110
+
111
+ ```ruby
112
+ class User < Rasti::Model
113
+ attribute :name, T::String, description: 'The user full name'
114
+ end
115
+
116
+ attribute = User.attributes.first
117
+ attribute.option(:description) # => 'The user full name'
118
+ ```
119
+
120
+ These options are also included in the schema representation.
121
+
122
+ ### Equality
123
+ ```ruby
124
+ point_1 = Point.new x: 1, y: 2
125
+ point_2 = Point.new x: 1, y: 2
126
+ point_3 = Point.new x: 2, y: 1
127
+
128
+ point_1 == point_2 # => true
129
+ point_1 == point_3 # => false
80
130
  ```
81
131
 
82
132
  ### Error handling
@@ -86,8 +136,41 @@ TypedPoint = Rasti::Model[x: T::Integer, y: T::Integer]
86
136
  point = TypedPoint.new x: true
87
137
  point.x # => Rasti::Types::CastError: Invalid cast: true -> Rasti::Types::Integer
88
138
  point.y # => Rasti::Model::NotAssignedAttributeError: Not assigned attribute y
139
+
140
+ # Bulk validation
141
+ point = TypedPoint.new x: 'invalid', y: 'invalid'
142
+ point.cast_attributes! # => Rasti::Types::CompoundError: x: ["Invalid cast: \"invalid\" -> Rasti::Types::Integer"], y: ["Invalid cast: \"invalid\" -> Rasti::Types::Integer"]
143
+ ```
144
+
145
+ ### Model Schema
146
+ It is possible to obtain a serializable representation of the model structure (schema).
147
+
148
+ ```ruby
149
+ Point = Rasti::Model[x: T::Integer, y: T::Integer]
150
+ Point.to_schema
151
+ # => {
152
+ # model: "Point",
153
+ # attributes: [
154
+ # {name: :x, type: :integer},
155
+ # {name: :y, type: :integer}
156
+ # ]
157
+ # }
158
+ ```
159
+
160
+ #### Custom type serializers
161
+ You can register custom serializers for your types to be used in the schema generation:
162
+
163
+ ```ruby
164
+ Rasti::Model::Schema.register_type_serializer(MyCustomType, :custom)
165
+
166
+ # Or with a block for more details
167
+ Rasti::Model::Schema.register_type_serializer(MyCustomType) do |type|
168
+ {type: :custom, details: type.some_info}
169
+ end
89
170
  ```
90
171
 
172
+ Also, if a type responds to `to_schema`, it will be used.
173
+
91
174
  ## Contributing
92
175
 
93
176
  Bug reports and pull requests are welcome on GitHub at https://github.com/gabynaiman/rasti-model.
@@ -0,0 +1,89 @@
1
+ module Rasti
2
+ class Model
3
+ module Schema
4
+ class << self
5
+
6
+ def register_type_serializer(type, serialized_type=nil, &block)
7
+ type_serializers[type] = block || serialized_type
8
+ end
9
+
10
+ def serialize(model_class, visited=Set.new)
11
+ attributes = model_class.attributes.map do |attribute|
12
+ serialize_attribute(attribute, visited).merge(name: attribute.name)
13
+ end
14
+
15
+ {
16
+ model: model_class.name || model_class.to_s,
17
+ attributes: attributes
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def type_serializers
24
+ @type_serializers ||= {}
25
+ end
26
+
27
+ def serialize_attribute(attribute, visited)
28
+ serialization = serialize_type(attribute.type, visited)
29
+
30
+ options = attribute.send(:options)
31
+ serialization[:options] = options unless options.empty?
32
+
33
+ serialization
34
+ end
35
+
36
+ def serialize_type(type, visited=Set.new)
37
+ if type.nil?
38
+ return {type: :any}
39
+ end
40
+
41
+ if (serializer = type_serializers[type])
42
+ return serializer.is_a?(Proc) ? serializer.call(type) : {type: serializer}
43
+ end
44
+
45
+ if type.respond_to? :to_schema
46
+ return type.to_schema
47
+ end
48
+
49
+ if type.is_a?(Class)
50
+ if type <= Types::String
51
+ {type: :string}
52
+ elsif type <= Types::Integer
53
+ {type: :integer}
54
+ elsif type <= Types::Float
55
+ {type: :float}
56
+ elsif type <= Types::Boolean
57
+ {type: :boolean}
58
+ elsif type <= Types::Symbol
59
+ {type: :symbol}
60
+ elsif type <= Types::Regexp
61
+ {type: :regexp}
62
+ else
63
+ {type: :unknown, details: type.name || type.to_s}
64
+ end
65
+ elsif type.is_a?(Types::Time)
66
+ {type: :time, format: type.format}
67
+ elsif type.respond_to?(:values)
68
+ {type: :enum, values: type.values}
69
+ elsif type.is_a?(Types::Array)
70
+ {type: :array, items: serialize_type(type.type, visited)}
71
+ elsif type.is_a?(Types::Hash)
72
+ {type: :hash, key_type: serialize_type(type.key_type, visited), value_type: serialize_type(type.value_type, visited)}
73
+ elsif type.is_a?(Types::Model)
74
+ model = type.model
75
+ model_name = model.name || model.to_s
76
+ if visited.include?(model)
77
+ {type: :model, model: model_name, schema: {model: model_name, attributes: []}}
78
+ else
79
+ {type: :model, model: model_name, schema: serialize(model, visited | Set[model])}
80
+ end
81
+ else
82
+ {type: :unknown, details: type.to_s}
83
+ end
84
+ end
85
+
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,5 +1,5 @@
1
1
  module Rasti
2
2
  class Model
3
- VERSION = '2.0.1'
3
+ VERSION = '2.1.1'
4
4
  end
5
5
  end
data/lib/rasti/model.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'multi_require'
2
2
  require 'rasti-types'
3
+ require 'set'
3
4
 
4
5
  module Rasti
5
6
  class Model
@@ -32,6 +33,10 @@ module Rasti
32
33
  name || self.superclass.name
33
34
  end
34
35
 
36
+ def to_schema
37
+ Schema.serialize self
38
+ end
39
+
35
40
  def to_s
36
41
  "#{model_name}[#{attribute_names.join(', ')}]"
37
42
  end
data/spec/model_spec.rb CHANGED
@@ -290,4 +290,139 @@ describe Rasti::Model do
290
290
  error.message.must_equal 'Attribute x already exists'
291
291
  end
292
292
 
293
+ describe 'Schema' do
294
+
295
+ it 'Simple' do
296
+ model = Rasti::Model[id: T::Integer, name: T::String]
297
+
298
+ model.to_schema.must_equal({
299
+ model: model.to_s,
300
+ attributes: [
301
+ {name: :id, type: :integer},
302
+ {name: :name, type: :string}
303
+ ]
304
+ })
305
+ end
306
+
307
+ it 'Complex' do
308
+ address_class = Rasti::Model[street: T::String, number: T::Integer]
309
+
310
+ model = Rasti::Model[
311
+ id: T::Integer,
312
+ tags: T::Array[T::String],
313
+ address: T::Model[address_class],
314
+ metadata: T::Hash[T::Symbol, T::String],
315
+ status: T::Enum['active', 'inactive'],
316
+ created_at: T::Time['%Y-%m-%d']
317
+ ]
318
+
319
+ model.to_schema.must_equal({
320
+ model: model.to_s,
321
+ attributes: [
322
+ {name: :id, type: :integer},
323
+ {name: :tags, type: :array, items: {type: :string}},
324
+ {name: :address, type: :model, model: address_class.to_s, schema: {
325
+ model: address_class.to_s,
326
+ attributes: [
327
+ {name: :street, type: :string},
328
+ {name: :number, type: :integer}
329
+ ]
330
+ }},
331
+ {name: :metadata, type: :hash, key_type: {type: :symbol}, value_type: {type: :string}},
332
+ {name: :status, type: :enum, values: ['active', 'inactive']},
333
+ {name: :created_at, type: :time, format: '%Y-%m-%d'}
334
+ ]
335
+ })
336
+ end
337
+
338
+ it 'With options' do
339
+ model = Class.new(Rasti::Model) do
340
+ attribute :text, T::String, description: 'Basic text'
341
+ end
342
+
343
+ model.to_schema.must_equal({
344
+ model: model.to_s,
345
+ attributes: [
346
+ {name: :text, type: :string, options: {description: 'Basic text'}}
347
+ ]
348
+ })
349
+ end
350
+
351
+ it 'Type with to_schema' do
352
+ custom_type = Object.new
353
+ custom_type.define_singleton_method :to_schema do
354
+ {type: :custom, details: 'custom details'}
355
+ end
356
+
357
+ model = Rasti::Model[attr: custom_type]
358
+
359
+ model.to_schema.must_equal({
360
+ model: model.to_s,
361
+ attributes: [
362
+ {name: :attr, type: :custom, details: 'custom details'}
363
+ ]
364
+ })
365
+ end
366
+
367
+ it 'Self-referential model' do
368
+ node_class = Class.new(Rasti::Model) do
369
+ attribute :name, T::String
370
+ end
371
+ node_class.send(:attribute, :child, T::Model[node_class])
372
+
373
+ node_name = node_class.to_s
374
+
375
+ node_class.to_schema.must_equal({
376
+ model: node_name,
377
+ attributes: [
378
+ {type: :string, name: :name},
379
+ {type: :model, model: node_name, schema: {
380
+ model: node_name,
381
+ attributes: [
382
+ {type: :string, name: :name},
383
+ {type: :model, model: node_name, schema: {model: node_name, attributes: []}, name: :child}
384
+ ]
385
+ }, name: :child}
386
+ ]
387
+ })
388
+ end
389
+
390
+ it 'Mutually recursive models' do
391
+ person_class = Class.new(Rasti::Model) do
392
+ attribute :name, T::String
393
+ end
394
+
395
+ company_class = Class.new(Rasti::Model) do
396
+ attribute :name, T::String
397
+ end
398
+
399
+ person_class.send(:attribute, :employer, T::Model[company_class])
400
+ company_class.send(:attribute, :ceo, T::Model[person_class])
401
+
402
+ person_name = person_class.to_s
403
+ company_name = company_class.to_s
404
+
405
+ person_class.to_schema.must_equal({
406
+ model: person_name,
407
+ attributes: [
408
+ {type: :string, name: :name},
409
+ {type: :model, model: company_name, schema: {
410
+ model: company_name,
411
+ attributes: [
412
+ {type: :string, name: :name},
413
+ {type: :model, model: person_name, schema: {
414
+ model: person_name,
415
+ attributes: [
416
+ {type: :string, name: :name},
417
+ {type: :model, model: company_name, schema: {model: company_name, attributes: []}, name: :employer}
418
+ ]
419
+ }, name: :ceo}
420
+ ]
421
+ }, name: :employer}
422
+ ]
423
+ })
424
+ end
425
+
426
+ end
427
+
293
428
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rasti-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Naiman
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-11 00:00:00.000000000 Z
11
+ date: 2026-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: multi_require
@@ -162,6 +162,7 @@ files:
162
162
  - lib/rasti/model.rb
163
163
  - lib/rasti/model/attribute.rb
164
164
  - lib/rasti/model/errors.rb
165
+ - lib/rasti/model/schema.rb
165
166
  - lib/rasti/model/version.rb
166
167
  - rasti-model.gemspec
167
168
  - spec/coverage_helper.rb
@@ -171,7 +172,7 @@ homepage: https://github.com/gabynaiman/rasti-model
171
172
  licenses:
172
173
  - MIT
173
174
  metadata: {}
174
- post_install_message:
175
+ post_install_message:
175
176
  rdoc_options: []
176
177
  require_paths:
177
178
  - lib
@@ -186,8 +187,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
186
187
  - !ruby/object:Gem::Version
187
188
  version: '0'
188
189
  requirements: []
189
- rubygems_version: 3.0.6
190
- signing_key:
190
+ rubygems_version: 3.0.9
191
+ signing_key:
191
192
  specification_version: 4
192
193
  summary: Domain models with typed attributes
193
194
  test_files: