artisanal-model 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 74c99f3a53173417323c014e30722f54063402e2ed9a2e1e615131ff8ed02402
4
+ data.tar.gz: 33422ea03f1cdedad2ab332e4874525a367d9a344743cdd65378ad2f425b56a7
5
+ SHA512:
6
+ metadata.gz: 713b969405ef5bc54e0604e6f99fbca0e154051836588e6208a5fb9e283eab7cf38825f321e4cb4dcbf184bfe948a7acbf3a33fead7270d58b604c46e43badef
7
+ data.tar.gz: 3a606d437f1db3d241c3530b08e1be9d461525dea970861a2dc5d7fde4db5970a836c0019944a498d0b0728f7d70d0dbe0bb335b634705b97cc7a1205d002721
@@ -0,0 +1,17 @@
1
+ version: 2.1
2
+
3
+ orbs:
4
+ gem: goldstar/publish-gem@1.0.1
5
+
6
+ workflows:
7
+ main:
8
+ jobs:
9
+ - gem/test
10
+ - gem/build-and-deploy:
11
+ context: packagecloud
12
+ requires:
13
+ - gem/test
14
+ filters:
15
+ branches:
16
+ only: master
17
+ packagecloud-repo: goldstar/production
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /*.gem
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.3.7
7
+ before_install: gem install bundler -v 1.17.1
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in artisanal-model.gemspec
6
+ gemspec
7
+
8
+ group :benchmarks do
9
+ if RUBY_VERSION < "2.4"
10
+ gem "activesupport", "< 5"
11
+ else
12
+ gem "activesupport"
13
+ end
14
+
15
+ gem "benchmark-ips", "~> 2.5"
16
+ gem "dry-initializer"
17
+ gem "hashie"
18
+ gem "virtus"
19
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Jared Hoyt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,333 @@
1
+ # artisanal-model
2
+
3
+ Artisanal::Model is a light-weight attribute modeling DSL that wraps [dry-initializer](https://dry-rb.org/gems/dry-initializer/), providing extra configuration and a slightly cleaner DSL.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'artisanal-model'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install artisanal-model
20
+
21
+ ## Configuration
22
+
23
+ Artisanal::Model configuration is done on a per-model basis. There is no global configuration (at this time):
24
+
25
+ ```ruby
26
+ class Model
27
+ include Artisanal::Model(writable: true)
28
+ end
29
+ ```
30
+
31
+ The configuration will be carried down to subclasses automatically. However, and subclass can override any settings with the `configure` method:
32
+
33
+ ```ruby
34
+ class Person < Model
35
+ artisanal_model.configure { |config| config.writable = false }
36
+ # ... or
37
+ artisanal_model.config.writable = false
38
+ end
39
+ ```
40
+
41
+ #### `defaults(hash)`
42
+
43
+ The `defaults` setting allows you to provide default values to the `attribute` dsl method. For example, if you would like all attributes to be optional and private:
44
+
45
+ ```ruby
46
+ class Model
47
+ include Artisanal::Model(defaults: { optional: true, reader: :private })
48
+ end
49
+ ```
50
+
51
+ See the [dry-initializer](https://dry-rb.org/gems/dry-initializer/) documentation for a list of most of the options that can be passed to the `attribute` method.
52
+
53
+ #### `writable(boolean, default: false)`
54
+
55
+ Setting `writable` to true will enable the mass-assignment `#assign_attributes` method as well as add `writer: true` to the `defaults` configuration option.
56
+
57
+ You can also manually add `writer: true` to `defaults` without setting `writable` to true. This would give you attribute writers but skip creating the mass-assignment method.
58
+
59
+ #### `undefined(boolean, default: false)`
60
+
61
+ Setting `undefined` to true will configure dry-initializer to differentiate between `nil` values and undefineds. It will also automatically filter out undefined values when serializing your model to a hash.
62
+
63
+ See dry-initializer [Skip Undefined](https://dry-rb.org/gems/dry-initializer/skip-undefined/) documentation for more information.
64
+
65
+ #### `symbolize(boolean, default: false)`
66
+
67
+ Setting `symbolize` to true will make artisanal-model intern the keys of any attributes passed in during initialization and mass-assignment. Only attributes belonging to the model will be symbolized; all
68
+ other keys will be left as strings.
69
+
70
+ See the [integration test](blob/master/spec/artisanal/integration/stringified_arguments_spec.rb) for more details.
71
+ See the [benchmarks](#benchmarks) for the performance impact of "indifferent access".
72
+
73
+ ## Examples
74
+
75
+ For the following examples, consider the following `Model` class with some default configuration.
76
+
77
+ ```ruby
78
+ class Model
79
+ include Artisanal::Model(
80
+ defaults: { optional: true, type: Dry::Types::Any }
81
+ )
82
+ end
83
+ ```
84
+
85
+ You can define attributes on your models using Artisanal::Model's `attribute` dsl method:
86
+
87
+ ```ruby
88
+ class Person < Model
89
+ attribute :first_name
90
+ attribute :last_name
91
+ attribute :email
92
+ end
93
+
94
+ Person.new(first_name: 'John', last_name: 'Smith', email: 'john@example.com').tap do |person|
95
+ person.first_name #=> 'John'
96
+ person.email #=> 'john@example.com'
97
+ end
98
+ ```
99
+
100
+ Also, the keys passed into the initializer do not need to be symbolized ahead of time. Artisanal::Model will take care of that for you before passing them into dry-initializer:
101
+
102
+ ```ruby
103
+ Person.new('first_name' => 'John').tap do |person|
104
+ person.first_name #=> 'John'
105
+ end
106
+ ```
107
+
108
+ ### dry-initializer
109
+
110
+ For the most part, the parameters available to the `attribute` method are the same ones available to dry-initializer's [option method](https://dry-rb.org/gems/dry-initializer/params-and-options/). These allow you to do things like coerce values, rename incoming fields, set defaults, define required fields, and set method access control.
111
+
112
+ ```ruby
113
+ class Person < Model
114
+ attribute :first_name, default: -> { 'Bob' }
115
+ attribute :last_name, optional: false
116
+ attribute :email, from: :email_address
117
+ attribute :phone, reader: :private
118
+ attribute :age, ->(value, person) { value.to_i }
119
+ end
120
+
121
+ attrs = {
122
+ last_name: 'Smith',
123
+ email_address: 'john@example.com',
124
+ phone: '555.123.4567',
125
+ age: '37'
126
+ }
127
+
128
+ Person.new(attrs).tap do |person|
129
+ person.first_name #=> 'Bob'
130
+ person.email #=> 'john@example.com'
131
+ person.phone #=> NoMethodError: private method `phone' called for...
132
+ person.age #=> 37
133
+ end
134
+
135
+ Person.new(first_name: 'Steve')
136
+ #=> KeyError: Person: option 'last_name' is required
137
+ ```
138
+
139
+ ### aliased fields
140
+
141
+ The dry-initializer gem already lets you use the `:as` option to give your field a new name. To make this a little more straightforward, artisanal-model adds a `:from` option that is the inverse of `:as`:
142
+
143
+ ```ruby
144
+ class Person < Model
145
+ attribute :email_address, as: :email
146
+ # is the same as ...
147
+ attribute :email, from: :email_address
148
+ end
149
+
150
+ Person.new(email_address: 'john@example.com').email #=> 'john@example.com'
151
+ ```
152
+
153
+ ### coercions
154
+
155
+ In addition to the functionality dry-initializer provides, Artisanal::Model also adds some niceties that make the dsl a little less verbose. For example, coercions in dry-initializer are required to be a callable type (e.g. a proc or a [dry-type](https://dry-rb.org/gems/dry-types/)).
156
+
157
+ However, Artisanal::Model will allow you to specify a class or an array and will wrap the type coercion with a proc in the background:
158
+
159
+ ```ruby
160
+ class Address < Model
161
+ attribute :street
162
+ attribute :city
163
+ attribute :state
164
+ attribute :zip
165
+ end
166
+
167
+ class Tag < Model
168
+ attribute :name
169
+ end
170
+
171
+ class Person < Model
172
+ attribute :name
173
+ attribute :address, Address
174
+ attribute :tags, Array[Tag]
175
+ attribute :emails, Set[Dry::Types['string']]
176
+ end
177
+
178
+ attrs = {
179
+ name: 'John Smith',
180
+ address: {
181
+ street: '123 Main St.',
182
+ city: 'Portland',
183
+ state: 'OR',
184
+ zip: '97213'
185
+ },
186
+ tags: [
187
+ { name: 'Ruby' },
188
+ { name: 'Developer' }
189
+ ],
190
+ email: ['john@example.com', 'jsmith@example.com']
191
+ }
192
+
193
+ Person.new(attrs).tap do |person|
194
+ person.name #=> 'John Smith'
195
+
196
+ person.address.street #=> '123 Main St.'
197
+ person.address.zip #=> '97213'
198
+
199
+ person.tags.count #=> 2
200
+ person.tags.first.name #=> 'Ruby'
201
+ end
202
+ ```
203
+
204
+ ### writers
205
+
206
+ Artisanal::Model can also add writer methods that aren't provided from dry-initializer:
207
+
208
+ ```ruby
209
+ # Model.include Artisanal::Model(writable: true, ...)
210
+
211
+ class Person < Model
212
+ attribute :name
213
+ attribute :email, writer: false
214
+ attribute :phone, writer: :protected # the same as adding `protected :phone`
215
+ attribute :age, writer: :private # the same as adding `private :age`
216
+ end
217
+
218
+ attrs = {
219
+ name: 'John',
220
+ email: 'john@example.com',
221
+ phone: '555.123.4567',
222
+ age: '37'
223
+ }
224
+
225
+ Person.new(attrs).tap do |person|
226
+ person.name = 'Bob'
227
+ person.name #=> 'Bob'
228
+
229
+ person.email = 'bob@example.com' # => NoMethodError: undefined method `email' called for ...
230
+ person.phone = '555.987.6543' # => NoMethodError: protected method `phone' called for ...
231
+ person.age = '21' # => NoMethodError: private method `age' called for ...
232
+ end
233
+ ```
234
+
235
+ Notice that any other value except for `false`, `:protected` and `:private` provides a public writer.
236
+
237
+ With `writable` enabled, models will also have a `assign_attributes` method to do attribute mass-assignment:
238
+
239
+ ```ruby
240
+ class Person < Model
241
+ attribute :name
242
+ attribute :email
243
+ attribute :age
244
+ end
245
+
246
+ Person.new(name: 'John Smith', email: 'john@example.com', age: '37').tap do |person|
247
+ person.name #=> 'John Smith'
248
+
249
+ person.assign_attributes(name: 'Bob Johnson', email: 'bob@example.com')
250
+
251
+ person.name #=> 'Bob Johnson'
252
+ person.email #=> 'bob@example.com'
253
+ person.age #=> '37'
254
+ end
255
+ ```
256
+
257
+ ### serialization
258
+
259
+ Artisanal::Models can also be converted back into hashes for storage or representation purposes. By default, the result will only include public attributes, but `to_h` will also let you request private attributes as well:
260
+
261
+ ```ruby
262
+ class Person < Model
263
+ attribute :name
264
+ attribute :email
265
+ attribute :phone, reader: :private
266
+ attribute :age, reader: :protected
267
+ end
268
+
269
+ Person.new(name: 'John Smith', phone: '555.123.4567', age: '37').tap do |person|
270
+ person.to_h #=> { name: 'John Smith', email: nil }
271
+ person.to_h(scope: :private) #=> { phone: '555.123.4567' }
272
+ person.to_h(scope: [:public, :protected]) #=> { name: 'John Smith', email: nil, age: '37' }
273
+ person.to_h(scope: :all) #=> { name: 'John Smith', email: nil, phone: '555.123.4567', age: '37' }
274
+ end
275
+ ```
276
+
277
+ ### undefined attributes
278
+
279
+ Dry-initializer [differentiates](https://dry-rb.org/gems/dry-initializer/skip-undefined/) between a `nil` value passed in for an attribute and nothing passed in at all.
280
+
281
+ This can be turned off through Artisanal::Model for performance reasons if you don't care about the differences between `nil` and undefined. However, if turned on, serializing to a hash will also exclude undefined values by default:
282
+
283
+ ```ruby
284
+ # Model.include Artisanal::Model(undefined: true, ...)
285
+
286
+ class Person < Model
287
+ attribute :name
288
+ attribute :email
289
+ attribute :phone
290
+ end
291
+
292
+ Person.new(name: 'John Smith', phone: nil).tap do |person|
293
+ person.to_h #=> { name: 'John Smith', phone: nil }
294
+ person.to_h(include_undefined: true) #=> { name: 'John Smith', email: nil, phone: nil }
295
+ end
296
+ ```
297
+
298
+ ## Benchmarks
299
+
300
+ Comparing artisanal-model with plain ruby, dry-initializer, hashie, and virtus:
301
+
302
+ ```
303
+ Calculating -------------------------------------
304
+ plain Ruby 2.493M (± 2.8%) i/s - 37.407M in 15.016557s
305
+ dry-initializer 402.247k (± 2.7%) i/s - 6.051M in 15.054567s
306
+ artisanal-model 322.343k (± 3.2%) i/s - 4.843M in 15.040670s
307
+ artisanal-model (WITH WRITERS)
308
+ 329.785k (± 2.6%) i/s - 4.965M in 15.066329s
309
+ artisanal-model (WITH INDIFFERENT ACCESS)
310
+ 284.767k (± 2.2%) i/s - 4.292M in 15.078616s
311
+ hashie 37.250k (± 1.8%) i/s - 559.827k in 15.034072s
312
+ virtus 136.092k (± 2.0%) i/s - 2.049M in 15.059855s
313
+
314
+ Comparison:
315
+ plain Ruby: 2492919.5 i/s
316
+ dry-initializer: 402247.4 i/s - 6.20x slower
317
+ artisanal-model (WITH WRITERS): 329785.0 i/s - 7.56x slower
318
+ artisanal-model: 322342.7 i/s - 7.73x slower
319
+ artisanal-model (WITH INDIFFERENT ACCESS): 284766.8 i/s - 8.75x slower
320
+ virtus: 136092.4 i/s - 18.32x slower
321
+ hashie: 37250.4 i/s - 66.92x slower
322
+ ```
323
+
324
+ ## Development
325
+
326
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
327
+
328
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
329
+
330
+ ## Contributing
331
+
332
+ Bug reports and pull requests are welcome on GitHub at https://github.com/goldstar/artisanal-model.
333
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,36 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "artisanal/model/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "artisanal-model"
7
+ spec.version = Artisanal::Model::VERSION
8
+ spec.authors = ["Jared Hoyt, Matthew Peychich"]
9
+ spec.email = ["jaredhoyt@gmail.com, mpeychich@mac.com"]
10
+
11
+ spec.summary = %q{A light attributes wrapper for dry-initializer}
12
+ spec.description = %q{A light attributes wrapper for dry-initializer}
13
+ spec.homepage = "https://github.com/goldstar/artisanal-model"
14
+ spec.license = "MIT"
15
+
16
+ # Specify which files should be added to the gem when it is released.
17
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
18
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
20
+ end
21
+ spec.bindir = "exe"
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.required_ruby_version = ">= 2.4"
26
+
27
+ spec.add_runtime_dependency "dry-initializer", ">= 2.5.0"
28
+
29
+ spec.add_development_dependency "dry-types", ">= 0.13.3"
30
+ spec.add_development_dependency "pry", "~> 0.10"
31
+ spec.add_development_dependency "rb-readline"
32
+ spec.add_development_dependency "rake", "~> 10.0"
33
+ spec.add_development_dependency "rspec", "~> 3.0"
34
+ spec.add_development_dependency "rspec_junit_formatter"
35
+ spec.add_development_dependency "rspec-its", "~> 1.2"
36
+ end
@@ -0,0 +1,107 @@
1
+ Bundler.require(:benchmarks)
2
+
3
+ class PlainRubyTest
4
+ attr_reader :foo, :bar, :baz
5
+
6
+ def initialize(foo: "FOO", bar: "BAR", baz_old: "BAZ")
7
+ @foo = foo
8
+ @bar = bar
9
+ @baz = baz_old
10
+ raise TypeError unless String === @foo
11
+ raise TypeError unless String === @bar
12
+ end
13
+ end
14
+
15
+ require "dry-initializer"
16
+ class DryTest
17
+ extend Dry::Initializer[undefined: false]
18
+
19
+ option :foo, proc(&:to_s), default: -> { "FOO" }
20
+ option :bar, proc(&:to_s), default: -> { "BAR" }
21
+ option :baz_old, proc(&:to_s), default: -> { "BAZ" }, as: :baz
22
+ end
23
+
24
+ require "artisanal-model"
25
+ class ArtisanalTest
26
+ include Artisanal::Model
27
+
28
+ attribute :foo, proc(&:to_s), default: -> { "FOO" }
29
+ attribute :bar, proc(&:to_s), default: -> { "BAR" }
30
+ attribute :baz, proc(&:to_s), default: -> { "BAR" }, from: :baz_old
31
+ end
32
+
33
+ require "artisanal-model"
34
+ class ArtisanalTestWithWriters
35
+ include Artisanal::Model(writable: true)
36
+
37
+ attribute :foo, proc(&:to_s), default: -> { "FOO" }
38
+ attribute :bar, proc(&:to_s), default: -> { "BAR" }
39
+ attribute :baz, proc(&:to_s), default: -> { "BAR" }, from: :baz_old
40
+ end
41
+
42
+ require "artisanal-model"
43
+ class ArtisanalTestWithIndifferentAccess
44
+ include Artisanal::Model(symbolize: true)
45
+
46
+ attribute :foo, proc(&:to_s), default: -> { "FOO" }
47
+ attribute :bar, proc(&:to_s), default: -> { "BAR" }
48
+ attribute :baz, proc(&:to_s), default: -> { "BAR" }, from: :baz_old
49
+ end
50
+
51
+ require "hashie"
52
+ class HashieTest < Hashie::Trash
53
+ include Hashie::Extensions::Dash::Coercion
54
+ include Hashie::Extensions::IndifferentAccess
55
+ include Hashie::Extensions::MethodAccess
56
+
57
+ property :foo, coerce: proc(&:to_s), default: "FOO"
58
+ property :bar, coerce: proc(&:to_s), default: "BAR"
59
+ property :baz, coerce: proc(&:to_s), default: "BAR", from: :baz_old
60
+ end
61
+
62
+ require "virtus"
63
+ class VirtusTest
64
+ include Virtus.model
65
+
66
+ attribute :foo, String, default: "FOO"
67
+ attribute :bar, String, default: "BAR"
68
+ attribute :baz_old, String, default: "BAR"
69
+
70
+ alias_method :baz, :baz_old
71
+ end
72
+
73
+ puts "Benchmark for example models with virtus and hashie"
74
+
75
+ Benchmark.ips do |x|
76
+ x.config time: 15, warmup: 10
77
+
78
+ x.report("plain Ruby") do
79
+ PlainRubyTest.new
80
+ end
81
+
82
+ x.report("dry-initializer") do
83
+ DryTest.new
84
+ end
85
+
86
+ x.report("artisanal-model") do
87
+ ArtisanalTest.new
88
+ end
89
+
90
+ x.report("artisanal-model (WITH WRITERS)") do
91
+ ArtisanalTestWithWriters.new
92
+ end
93
+
94
+ x.report("artisanal-model (WITH INDIFFERENT ACCESS)") do
95
+ ArtisanalTestWithIndifferentAccess.new
96
+ end
97
+
98
+ x.report("hashie") do
99
+ HashieTest.new
100
+ end
101
+
102
+ x.report("virtus") do
103
+ VirtusTest.new
104
+ end
105
+
106
+ x.compare!
107
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "artisanal/model"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,86 @@
1
+ module Artisanal::Model
2
+ class Attribute < Module
3
+ attr_reader :name, :type, :options
4
+
5
+ def initialize(name, coercer=nil, **options)
6
+ @name, @options = name, options
7
+ @type = coercer || options[:type]
8
+
9
+ # Convert :from option to :as
10
+ if options.has_key? :from
11
+ @name, options[:as] = options[:from], name
12
+ end
13
+
14
+ # Add default values for certain types
15
+ unless options.has_key?(:default)
16
+ options.merge!(default: type_default)
17
+ end
18
+
19
+ raise ArgumentError.new("type missing for attribute #{name}") if type.nil?
20
+ end
21
+
22
+ def included(base)
23
+ # Create dry-initializer option
24
+ base.option(name, **options.merge(type: type_builder))
25
+
26
+ # Create writer method
27
+ define_writer(base, name) if options[:writer]
28
+ end
29
+
30
+ protected
31
+
32
+ def class_coercer(type)
33
+ if type.respond_to? :artisanal_model
34
+ ->(value, parent) { value.is_a?(type) ? value : type.new(value, parent) }
35
+ else
36
+ ->(value) { value.is_a?(type) ? value : type.new(value) }
37
+ end
38
+ end
39
+
40
+ def define_writer(base, target)
41
+ define_method("#{target}=") do |value|
42
+ coercer = artisanal_model.schema[target].type
43
+ arity = coercer.is_a?(Proc) ? coercer.arity : coercer.method(:call).arity
44
+ args = arity.abs == 1 ? [value] : [value, self]
45
+
46
+ coercer.call(*args).tap { |result| instance_variable_set("@#{target}", result) }
47
+ end
48
+
49
+ # Scope writer to protected or private
50
+ if [:protected, :private].include? options[:writer]
51
+ base.send(options[:writer], "#{target}=")
52
+ end
53
+ end
54
+
55
+ def enumerable_coercer(type)
56
+ coercer = type_builder(type.first)
57
+ arity = coercer.is_a?(Proc) ? coercer.arity : coercer.method(:call).arity
58
+
59
+ if arity.abs == 1
60
+ ->(collection) { type.class.new(collection.map { |value| coercer.call(value) }) }
61
+ else
62
+ ->(collection, parent) { type.class.new(collection.map { |value| coercer.call(value, parent) }) }
63
+ end
64
+ end
65
+
66
+ def type_builder(type=self.type)
67
+ case type
68
+ when Class
69
+ class_coercer(type)
70
+ when Enumerable
71
+ enumerable_coercer(type)
72
+ else
73
+ type
74
+ end
75
+ end
76
+
77
+ def type_default
78
+ case type
79
+ when Array
80
+ -> { Array.new }
81
+ when Set
82
+ -> { Set.new }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,40 @@
1
+ require 'dry-initializer'
2
+
3
+ module Artisanal::Model
4
+ require_relative 'dsl'
5
+
6
+ class Builder < Module
7
+ attr_reader :config
8
+
9
+ def initialize(options={})
10
+ @config = Config.new(options)
11
+ end
12
+
13
+ def included(base)
14
+ base.extend Dry::Initializer[undefined: config.undefined?]
15
+ base.extend Artisanal::Model::DSL
16
+
17
+ # Make attributes mutable
18
+ define_writers if config.writable?
19
+
20
+ # Store artisanal model config
21
+ base.artisanal_model.config = config
22
+ end
23
+
24
+ protected
25
+
26
+ def define_writers
27
+ # Add writers to all attributes
28
+ config.defaults[:writer] = true unless config.defaults.has_key? :writer
29
+
30
+ # Define mass-assignment method
31
+ define_method(:assign_attributes) do |attrs|
32
+ attrs = artisanal_model.symbolize(attrs)
33
+
34
+ (attrs.keys & artisanal_model.schema.keys).each do |key|
35
+ public_send("#{key}=", attrs[key]) if respond_to? "#{key}="
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ module Artisanal::Model
2
+ class Config
3
+ attr_reader :options, :defaults, :writable, :undefined, :symbolize
4
+
5
+ alias_method :writable?, :writable
6
+ alias_method :undefined?, :undefined
7
+ alias_method :symbolize?, :symbolize
8
+
9
+ def initialize(options={})
10
+ @options = options
11
+ @defaults = { optional: true }.merge(options.fetch(:defaults, {}))
12
+ @writable = options.fetch(:writable, false)
13
+ @undefined = options.fetch(:undefined, false)
14
+ @symbolize = options.fetch(:symbolize, false)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ module Artisanal::Model
2
+ require_relative 'initializer'
3
+ require_relative 'model'
4
+
5
+ module DSL
6
+ def self.extended(base)
7
+ base.prepend Initializer
8
+ base.include InstanceMethods
9
+ end
10
+
11
+ def inherited(subclass)
12
+ subclass.include Artisanal::Model(**artisanal_model.config.options)
13
+ super(subclass)
14
+ end
15
+
16
+ def artisanal_model
17
+ @artisanal_model ||= Model.new(self)
18
+ end
19
+
20
+ def schema
21
+ artisanal_model.schema
22
+ end
23
+
24
+ def attribute(*args, **kwargs)
25
+ artisanal_model.attribute(*args, **kwargs)
26
+ end
27
+
28
+ module InstanceMethods
29
+ def artisanal_model
30
+ self.class.artisanal_model
31
+ end
32
+
33
+ def attributes(*args)
34
+ artisanal_model.attributes(self, *args)
35
+ end
36
+
37
+ def to_h(**args)
38
+ artisanal_model.to_h(self, **args)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
@@ -0,0 +1,7 @@
1
+ module Artisanal::Model
2
+ module Initializer
3
+ def initialize(attributes={}, parent=nil)
4
+ super(**artisanal_model.symbolize(attributes))
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,93 @@
1
+ require 'forwardable'
2
+
3
+ module Artisanal::Model
4
+ require_relative 'attribute'
5
+
6
+ class Model
7
+ extend Forwardable
8
+
9
+ attr_reader :klass
10
+ attr_writer :config
11
+
12
+ delegate [:dry_initializer] => :klass
13
+ delegate [:definitions, :null] => :dry_initializer
14
+
15
+ alias_method :schema, :definitions
16
+
17
+ def initialize(klass)
18
+ @klass = klass
19
+ end
20
+
21
+ def attribute(name, type=nil, **opts)
22
+ klass.include Attribute.new(name, type, **config.defaults.merge(opts))
23
+ end
24
+
25
+ def attributes(instance, **kwargs)
26
+ scope = kwargs.fetch(:scope, :public)
27
+ include_undefined = kwargs.fetch(:include_undefined, false)
28
+ schema.values.each_with_object({}) do |item, attrs|
29
+ next unless attribute_in_scope?(instance, item.target, scope)
30
+ next unless include_undefined || attribute_defined?(instance, item.target)
31
+
32
+ attrs[item.target] = instance.send(item.target)
33
+ end
34
+ end
35
+
36
+ def config
37
+ @config ||= Config.new
38
+ end
39
+
40
+ def symbolize(attributes={})
41
+ attributes = attributes.to_h
42
+
43
+ return attributes unless config.symbolize?
44
+
45
+ attributes.dup.tap do |attrs|
46
+ (attribute_names & attrs.keys).each do |key|
47
+ attrs[key.intern] = attrs.delete(key)
48
+ end
49
+ end
50
+ end
51
+
52
+ def to_h(instance, **args)
53
+ attributes(instance, **args).each_with_object({}) do |(key, value), result|
54
+ if value.is_a? Hash
55
+ result[key] = value
56
+ elsif value.is_a? Enumerable
57
+ result[key] = value.map { |v| v.respond_to?(:to_h) ? v.to_h(**args) : v }
58
+ elsif !value.nil? && value.respond_to?(:to_h)
59
+ result[key] = value.to_h(**args)
60
+ else
61
+ result[key] = value
62
+ end
63
+ end
64
+ end
65
+
66
+ protected
67
+
68
+ def attribute_defined?(instance, name)
69
+ instance.instance_variable_get("@#{name}") != Dry::Initializer::UNDEFINED
70
+ end
71
+
72
+ def attribute_in_scope?(instance, name, scope)
73
+ scope = Array(scope)
74
+
75
+ (instance.respond_to?(name, true) && scope.include?(:all)) ||
76
+ (scope.include?(:public) && instance.public_methods.include?(name)) ||
77
+ (scope.include?(:protected) && instance.protected_methods.include?(name)) ||
78
+ (scope.include?(:private) && instance.private_methods.include?(name))
79
+ end
80
+
81
+ def attribute_names
82
+ @attribute_names ||= schema.keys.map(&:to_s)
83
+ end
84
+
85
+ def method_missing(method, *args)
86
+ if dry_initializer.respond_to?(method)
87
+ dry_initializer.send(method, *args)
88
+ else
89
+ super
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,5 @@
1
+ module Artisanal
2
+ module Model
3
+ VERSION = "0.2.0"
4
+ end
5
+ end
@@ -0,0 +1,15 @@
1
+ module Artisanal
2
+ require_relative "model/builder"
3
+ require_relative 'model/config'
4
+ require_relative "model/version"
5
+
6
+ def self.Model(**opts)
7
+ Model::Builder.new(**opts)
8
+ end
9
+
10
+ module Model
11
+ def self.included(base)
12
+ base.include Artisanal::Model()
13
+ end
14
+ end
15
+ end
@@ -0,0 +1 @@
1
+ require_relative 'artisanal/model'
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: artisanal-model
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Jared Hoyt, Matthew Peychich
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-04-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-initializer
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.5.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 2.5.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-types
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 0.13.3
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 0.13.3
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.10'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.10'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rb-readline
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '10.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '10.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec_junit_formatter
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec-its
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.2'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.2'
125
+ description: A light attributes wrapper for dry-initializer
126
+ email:
127
+ - jaredhoyt@gmail.com, mpeychich@mac.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".circleci/config.yml"
133
+ - ".gitignore"
134
+ - ".rspec"
135
+ - ".travis.yml"
136
+ - Gemfile
137
+ - LICENSE.txt
138
+ - README.md
139
+ - Rakefile
140
+ - artisanal-model.gemspec
141
+ - benchmarks/with_virtus_and_hashie.rb
142
+ - bin/console
143
+ - bin/setup
144
+ - lib/artisanal-model.rb
145
+ - lib/artisanal/model.rb
146
+ - lib/artisanal/model/attribute.rb
147
+ - lib/artisanal/model/builder.rb
148
+ - lib/artisanal/model/config.rb
149
+ - lib/artisanal/model/dsl.rb
150
+ - lib/artisanal/model/initializer.rb
151
+ - lib/artisanal/model/model.rb
152
+ - lib/artisanal/model/version.rb
153
+ homepage: https://github.com/goldstar/artisanal-model
154
+ licenses:
155
+ - MIT
156
+ metadata: {}
157
+ post_install_message:
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '2.4'
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubygems_version: 3.0.3.1
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: A light attributes wrapper for dry-initializer
176
+ test_files: []