attr_magic 0.1.2

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
+ SHA1:
3
+ metadata.gz: c5c17464091f895e95a549137979f6be96d48eae
4
+ data.tar.gz: f17e82aad2ebf8d6e28937c46b71e373038f488f
5
+ SHA512:
6
+ metadata.gz: 4c689dbcef43970967cfb0db1c2fb4ce98cfdb132ed7cea4da80a6acb8f0ed447bd47680cc496d0db0c0ad9eb36f1a2eaf9f7164c2a685eeb563bd96e813bbd4
7
+ data.tar.gz: b4772ada55e0fb12de57f589b06b1d9418b1e20717a3ca7326980d4bd2aa86931e773a924fc1814629b14e172ee2b61f362469bbbd0b28d4bc190fbf9ea1bcbf
data/.gitignore ADDED
@@ -0,0 +1,26 @@
1
+
2
+ # General Ruby, sorted by first letter.
3
+ /.bundle/
4
+ /coverage/
5
+ /doc/
6
+ /vendor/bundle/
7
+ /.yardoc/
8
+
9
+ # Project-specific.
10
+ /*.gem
11
+
12
+ # Old etc.
13
+ *.old*
14
+ *.orig
15
+ /*.patch
16
+ *.ref*
17
+
18
+ # Nonpersistent stuff.
19
+ TODO.md
20
+
21
+ # Shared VS Code settings.
22
+ /.vscode/*
23
+ !/.vscode/*.json
24
+
25
+ # Custom VS Code settings.
26
+ /*.code-workspace
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+
2
+ -fd
3
+ --require spec_helper
data/.yardopts ADDED
@@ -0,0 +1,6 @@
1
+
2
+ --default-return void
3
+
4
+ {lib}/**/*.rb
5
+ -
6
+ README-ru.md
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+
2
+ source "https://rubygems.org"
3
+
4
+ group :development do
5
+ gem "its"
6
+ gem "rspec"
7
+ gem "simplecov", require: false
8
+ gem "yard"
9
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,52 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ activesupport (5.2.8.1)
5
+ concurrent-ruby (~> 1.0, >= 1.0.2)
6
+ i18n (>= 0.7, < 2)
7
+ minitest (~> 5.1)
8
+ tzinfo (~> 1.1)
9
+ concurrent-ruby (1.2.2)
10
+ diff-lcs (1.5.0)
11
+ docile (1.3.5)
12
+ i18n (1.14.1)
13
+ concurrent-ruby (~> 1.0)
14
+ its (0.2.0)
15
+ rspec-core
16
+ json (2.6.3)
17
+ minitest (5.15.0)
18
+ rspec (3.12.0)
19
+ rspec-core (~> 3.12.0)
20
+ rspec-expectations (~> 3.12.0)
21
+ rspec-mocks (~> 3.12.0)
22
+ rspec-core (3.12.2)
23
+ rspec-support (~> 3.12.0)
24
+ rspec-expectations (3.12.3)
25
+ diff-lcs (>= 1.2.0, < 2.0)
26
+ rspec-support (~> 3.12.0)
27
+ rspec-mocks (3.12.6)
28
+ diff-lcs (>= 1.2.0, < 2.0)
29
+ rspec-support (~> 3.12.0)
30
+ rspec-support (3.12.1)
31
+ simplecov (0.17.1)
32
+ docile (~> 1.1)
33
+ json (>= 1.8, < 3)
34
+ simplecov-html (~> 0.10.0)
35
+ simplecov-html (0.10.2)
36
+ thread_safe (0.3.6)
37
+ tzinfo (1.2.11)
38
+ thread_safe (~> 0.1)
39
+ yard (0.9.34)
40
+
41
+ PLATFORMS
42
+ ruby
43
+
44
+ DEPENDENCIES
45
+ activesupport
46
+ its
47
+ rspec
48
+ simplecov
49
+ yard
50
+
51
+ BUNDLED WITH
52
+ 1.17.3
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2017-2023 Alex Fortuna
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README-ru.md ADDED
@@ -0,0 +1,212 @@
1
+
2
+ # Инструменты для реализации атрибутов-вычислителей
3
+
4
+ <!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=6 orderedList=false} -->
5
+
6
+ <!-- code_chunk_output -->
7
+
8
+ - [Введение](#введение)
9
+ - [Что даёт AttrMagic?](#что-даёт-attrmagic)
10
+ - [Установка](#установка)
11
+ - [Пример использования](#пример-использования)
12
+ - [`#igetset`](#igetset)
13
+ - [`#require_attr`](#require_attr)
14
+ - [`#igetwrite`](#igetwrite)
15
+ - [Copyright](#copyright)
16
+
17
+ <!-- /code_chunk_output -->
18
+
19
+ ## Введение
20
+
21
+ *An English version of this text is also available: [README.md](README.md).*
22
+
23
+ Атрибут-вычислитель (lazy attribute) — это метод-accessor, который производит некое вычисление при первом вызове,
24
+ мемоизирует результат в instance-переменную и возвращает результат. Например:
25
+
26
+ ```ruby
27
+ class Person
28
+ # @return [String]
29
+ attr_accessor :first_name, :last_name
30
+
31
+ attr_writer :full_name, :is_admin
32
+
33
+ def initialize(attrs = {})
34
+ attrs.each { |k, v| public_send("#{k}=", v) }
35
+ end
36
+
37
+ # @return [String]
38
+ def full_name
39
+ @full_name ||= begin
40
+ [first_name, last_name].compact.join(" ").strip
41
+ end
42
+ end
43
+
44
+ # @return [Boolean]
45
+ def is_admin
46
+ return @is_admin if defined? @is_admin
47
+ @is_admin = !!full_name.match(/admin/i)
48
+ end
49
+ end
50
+ ```
51
+
52
+ В примере выше `#full_name` и `#is_admin` — атрибуты-вычислители.
53
+
54
+ ### Что даёт AttrMagic?
55
+
56
+ Классам, содержащим атрибуты-вычислители, AttrMagic даёт:
57
+
58
+ 1. `#igetset` и `#igetwrite` для простой мемоизации любых значений, включая `false` и `nil`.
59
+ 2. `#require_attr` для валидации атрибутов, от которых зависит результат данного вычисления.
60
+
61
+ ## Установка
62
+
63
+ Добавляем в `Gemfile` нашего проекта:
64
+
65
+ ```ruby
66
+ gem "attr_magic"
67
+ #gem "attr_magic", git: "https://github.com/dadooda/attr_magic"
68
+ ```
69
+
70
+ ## Пример использования
71
+
72
+ Чтобы использовать фичу, загружаем её в класс:
73
+
74
+ ```ruby
75
+ class Person
76
+ AttrMagic.load(self)
77
+
78
+ end
79
+ ```
80
+
81
+ Методы AttrMagic теперь доступны в классе `Person`.
82
+ Теперь рассмотрим, какие инструменты стали нам доступны.
83
+
84
+ ### `#igetset`
85
+
86
+ В примере выше метод `#full_name` мемоизирует результат оператором `||=`.
87
+ Это вполне приемлемо, ведь результат вычисления — строка.
88
+
89
+ А вот реализация `#is_admin` гораздо более громоздка, ведь результат
90
+ может быть вычислен как `false`, стало быть `||=` не подойдёт.
91
+
92
+ Оба метода можно переписать с использованием `#igetset`:
93
+
94
+ ```ruby
95
+ class Person
96
+
97
+ def full_name
98
+ igetset(__method__) { [first_name, last_name].compact.join(" ").strip }
99
+ end
100
+
101
+ def is_admin
102
+ igetset(__method__) { !!full_name.match(/admin/i) }
103
+ end
104
+ end
105
+ ```
106
+
107
+ Методы приобрели короткий, единообразный, легко читаемый вид.
108
+ Теперь вычисление в `#is_admin` отчётливо видно, тогда как ранее оно тонуло в повторах
109
+ *is_admin* внутри метода, который и так назван этим словом.
110
+
111
+ ### `#require_attr`
112
+
113
+ В примере выше метод `#first_name` возвращает пустую строку, даже если атрибуты
114
+ `first_name` и `last_name` не присвоены или пусты.
115
+
116
+ С точки зрения внятности это поведение «на грани фола».
117
+ Ведь, скорее всего, результат `#full_name` будет выводиться в блоке информации о персоне или при обращении к персоне.
118
+ Пустая строка, даже правомерно вычисленная, вызовет в этой ситуации, как минимум, непонимание.
119
+
120
+ Конечно, экземпляр `Person` не виноват, что перед вызовом `#full_name` в нём не присвоили `first_name` и `last_name`.
121
+ Как говорится, garbage in — garbage out.
122
+
123
+ Однако, чем тупо «вредничать», возвращая невнятную пустоту, `#full_name` мог бы
124
+ помочь разработчику выявить эту ситуацию, чётко о ней просигналив.
125
+
126
+ Предположим, мы решили, что для корректного вычисления `#full_name` нам необходим,
127
+ как минимум, непустой `first_name`. Реализация может выглядеть так:
128
+
129
+ ```ruby
130
+ class Person
131
+
132
+ def full_name
133
+ igetset(__method__) do
134
+ require_attr :first_name
135
+ [first_name, last_name].compact.join(" ").strip
136
+ end
137
+ end
138
+ end
139
+ ```
140
+
141
+ Теперь посмотрим, как это работает:
142
+
143
+ ```ruby
144
+ Person.new.full_name
145
+ # RuntimeError: Attribute `first_name` must not be nil: nil
146
+ ```
147
+
148
+ Вроде неплохо. Вместо пустоты мы получили исключение с указанием на причину отказа:
149
+ не присвоен `first_name`. Смущают, однако, слова про `nil`, хотя речь идёт о строковом атрибуте.
150
+ А вдруг в `first_name` пустая строка, что тогда? Пробуем:
151
+
152
+ ```ruby
153
+ Person.new(first_name: "").full_name
154
+ # => ""
155
+
156
+ Person.new(first_name: " ").full_name
157
+ # => ""
158
+ ```
159
+
160
+ На пустую строку наш `require_attr` пока не реагирует, хотя суть требования была в том,
161
+ чтобы `first_name` был именно *не пустой.* Чуть-чуть доработаем код:
162
+
163
+ ```ruby
164
+ # Нужно для `Object#present?`.
165
+ require "active_support/core_ext/object/blank"
166
+
167
+ class Person
168
+
169
+ def full_name
170
+ igetset(__method__) do
171
+ require_attr :first_name, :present?
172
+ #require_attr :first_name, :not_blank? # Можно так.
173
+ [first_name, last_name].compact.join(" ").strip
174
+ end
175
+ end
176
+ end
177
+ ```
178
+
179
+ Снова пробуем вызвать:
180
+
181
+ ```ruby
182
+ Person.new.full_name
183
+ # RuntimeError: Attribute `first_name` must be present: nil
184
+
185
+ Person.new(first_name: " ").full_name
186
+ # RuntimeError: Attribute `first_name` must be present: " "
187
+
188
+ Person.new(first_name: "Joe").full_name
189
+ # => "Joe"
190
+ ```
191
+
192
+ Теперь и сообщение чётче, и требование выполнено.
193
+
194
+ Мы узнали, что `#require_attr` даёт возможность выполнить тривиальную валидацию
195
+ соседнего атрибута, значение которого нужно в данном вычислении.
196
+
197
+ ### `#igetwrite`
198
+
199
+ Описанный в примере выше, метод `#igetset` взаимодействует с instance-переменными напрямую:
200
+ проверяет наличие, читает, записывает. В большинстве случаев этого достаточно.
201
+
202
+ Бывает, однако, что мы добавляем наш метод-вычислитель в класс, требующий записи в свои атрибуты
203
+ строго через write-аксессоры вроде `#name=`.
204
+
205
+ В таких случаях помогает `#igetwrite`.
206
+ Выполнив вычисление, он записывает значение в объект через write accessor.
207
+
208
+ ## Copyright
209
+
210
+ Продукт распространяется свободно на условиях лицензии MIT.
211
+
212
+ — © 2017-2023 Алексей Фортуна
data/README.md ADDED
@@ -0,0 +1,216 @@
1
+
2
+ # The tools to ease lazy attribute implementation
3
+
4
+ <!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=6 orderedList=false} -->
5
+
6
+ <!-- code_chunk_output -->
7
+
8
+ - [Overview](#overview)
9
+ - [What does AttrMagic provide?](#what-does-attrmagic-provide)
10
+ - [Setup](#setup)
11
+ - [Usage example](#usage-example)
12
+ - [`#igetset`](#igetset)
13
+ - [`#require_attr`](#require_attr)
14
+ - [`#igetwrite`](#igetwrite)
15
+ - [Copyright](#copyright)
16
+
17
+ <!-- /code_chunk_output -->
18
+
19
+ ## Overview
20
+
21
+ *Этот текст можно прочитать на русском языке: [README-ru.md](README-ru.md).*
22
+
23
+ A lazy attribute is an accessor method that performs some computation the first time it is called,
24
+ memoizes the result into an instance variable, and returns the result. Example:
25
+
26
+ ```ruby
27
+ class Person
28
+ # @return [String]
29
+ attr_accessor :first_name, :last_name
30
+
31
+ attr_writer :full_name, :is_admin
32
+
33
+ def initialize(attrs = {})
34
+ attrs.each { |k, v| public_send("#{k}=", v) }
35
+ end
36
+
37
+ # @return [String]
38
+ def full_name
39
+ @full_name ||= begin
40
+ [first_name, last_name].compact.join(" ").strip
41
+ end
42
+ end
43
+
44
+ # @return [Boolean]
45
+ def is_admin
46
+ return @is_admin if defined? @is_admin
47
+ @is_admin = !!full_name.match(/admin/i)
48
+ end
49
+ end
50
+ ```
51
+
52
+ Methods `#full_name` и `#is_admin` are lazy attributes.
53
+
54
+ ### What does AttrMagic provide?
55
+
56
+ For classes that have lazy attributes, AttrMagic provides:
57
+
58
+ 1. `#igetset` and `#igetwrite` for simple memoization of any values, including `false` and `nil`.
59
+ 2. `#require_attr` to validate attributes which are required by the given computation.
60
+
61
+ ## Setup
62
+
63
+ Add to your project's `Gemfile`:
64
+
65
+ ```ruby
66
+ gem "attr_magic"
67
+ #gem "attr_magic", git: "https://github.com/dadooda/attr_magic"
68
+ ```
69
+
70
+ ## Usage example
71
+
72
+ To use feature, let's load it into a class:
73
+
74
+ ```ruby
75
+ class Person
76
+ AttrMagic.load(self)
77
+
78
+ end
79
+ ```
80
+
81
+ AttrMagic methods are now available in the `Person` class.
82
+ Let's see how we can use them.
83
+
84
+ ### `#igetset`
85
+
86
+ In the example above, method `#full_name` memoizes the result with the `||=` operator.
87
+ This suits us, because the result of the computation is a string.
88
+
89
+ As to `#is_admin`, its much more verbose: the result can be `false`,
90
+ thus operator `||=` won't work.
91
+
92
+ Both methods can be written using `#igetset`:
93
+
94
+ ```ruby
95
+ class Person
96
+
97
+ def full_name
98
+ igetset(__method__) { [first_name, last_name].compact.join(" ").strip }
99
+ end
100
+
101
+ def is_admin
102
+ igetset(__method__) { !!full_name.match(/admin/i) }
103
+ end
104
+ end
105
+ ```
106
+
107
+ Now our methods are short, uniform, and easy to read.
108
+
109
+ Also, the computation in `#is_admin` is clearly visible, whereas previously it was obscured
110
+ by repetitions of *is_admin* inside the method already named by this word.
111
+
112
+ ### `#require_attr`
113
+
114
+ In the example above, method `#first_name` returns an empty string even if attributes
115
+ `first_name` and `last_name` are unassigned or blank.
116
+
117
+ Such behavior cannot be considered completely sane.
118
+ Most likely, the result of `#full_name` will be displayed in the info block about a person
119
+ or be used when addressing a person.
120
+ An empty string, even “legitimately” computed, may cause confusion.
121
+
122
+ Of course, it's not the `Person` instance's fault that neither `first_name` nor `last_name`
123
+ were assigned values prior to calling `#full_name`. Garbage in — garbage out.
124
+
125
+ However, rather than behaving deliberately “harmful” by returning inarticulate blankness,
126
+ `#full_name` could be *helpful* by signalling about this situation and helping us tackle it promptly.
127
+
128
+ Suppose we decided, that in order to compute `#full_name` properly,
129
+ we at least need a non-blank `first_name`. An implementation could look like this:
130
+
131
+ ```ruby
132
+ class Person
133
+
134
+ def full_name
135
+ igetset(__method__) do
136
+ require_attr :first_name
137
+ [first_name, last_name].compact.join(" ").strip
138
+ end
139
+ end
140
+ end
141
+ ```
142
+
143
+ Let's see how it works:
144
+
145
+ ```ruby
146
+ Person.new.full_name
147
+ # RuntimeError: Attribute `first_name` must not be nil: nil
148
+ ```
149
+
150
+ Not bad! Instead of getting dumb blankness, we've got an exception pointing straight at the reason:
151
+ unassigned `first_name`. However, the `nil` phrasing might sound a bit confusing
152
+ when talking about string values.
153
+ Also, what if `first_name` is assigned, but is empty or blank? Let's see:
154
+
155
+ ```ruby
156
+ Person.new(first_name: "").full_name
157
+ # => ""
158
+
159
+ Person.new(first_name: " ").full_name
160
+ # => ""
161
+ ```
162
+
163
+ Our `require_attr` doesn't react to an empty/blank string yet,
164
+ despite our decision to ensure that `first_name` is *non-blank*.
165
+ Let's tune the code a bit:
166
+
167
+ ```ruby
168
+ # We need this for `Object#present?`.
169
+ require "active_support/core_ext/object/blank"
170
+
171
+ class Person
172
+
173
+ def full_name
174
+ igetset(__method__) do
175
+ require_attr :first_name, :present?
176
+ #require_attr :first_name, :not_blank? # Also possible.
177
+ [first_name, last_name].compact.join(" ").strip
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ Let's try it again:
184
+
185
+ ```ruby
186
+ Person.new.full_name
187
+ # RuntimeError: Attribute `first_name` must be present: nil
188
+
189
+ Person.new(first_name: " ").full_name
190
+ # RuntimeError: Attribute `first_name` must be present: " "
191
+
192
+ Person.new(first_name: "Joe").full_name
193
+ # => "Joe"
194
+ ```
195
+
196
+ Now the message is more meaningful and the requirement is met.
197
+
198
+ We've learned that `#require_attr` makes it possible to perform a trivial validation
199
+ of another attribute, needed for a given computation to succeed.
200
+
201
+ ### `#igetwrite`
202
+
203
+ Method `#igetset`, described above, operates instance variables directly:
204
+ checks for being defined, gets and sets. This is sufficient in most cases.
205
+
206
+ Sometimes, however, proper setting of an attribute demands calling its write accessor,
207
+ such as `#name=`.
208
+
209
+ In such cases, `#igetwrite` comes in handy.
210
+ After performing the computation, it saves the value to the object by calling a write accessor.
211
+
212
+ ## Copyright
213
+
214
+ The product is free software distributed under the terms of the MIT license.
215
+
216
+ — © 2017-2023 Alex Fortuna
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+
2
+ desc "Build the gem"
3
+ task :build do
4
+ system "gem build attr_magic.gemspec"
5
+ end
6
+
7
+ desc "Build YARD docs"
8
+ task :doc do
9
+ system "bundle exec yard"
10
+ end
11
+
12
+ desc "Run tests"
13
+ task :test do
14
+ system "bundle exec rspec"
15
+ end
@@ -0,0 +1,17 @@
1
+
2
+ require_relative "lib/attr_magic/version"
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "attr_magic"
6
+ s.summary = "The tools to ease lazy attribute implementation"
7
+ s.version = AttrMagic::VERSION
8
+
9
+ s.authors = ["Alex Fortuna"]
10
+ s.email = ["fortunadze@gmail.com"]
11
+ s.homepage = "https://github.com/dadooda/attr_magic"
12
+ s.license = "MIT"
13
+
14
+ s.files = `git ls-files`.split("\n")
15
+ s.require_paths = ["lib"]
16
+ s.test_files = `git ls-files -- {spec}/*`.split("\n")
17
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AttrMagic
4
+ module InstanceMethods
5
+ # Memoize a lazy attribute, given its computation block.
6
+ #
7
+ # def full_name
8
+ # igetset(__method__) { [first_name, last_name].compact.join(" ").strip }
9
+ # end
10
+ #
11
+ # @param [Symbol | String] name
12
+ # @return [mixed] The result of +compute+.
13
+ # @see #igetwrite
14
+ def igetset(name, &compute)
15
+ if instance_variable_defined?(k = "@#{name}")
16
+ instance_variable_get(k)
17
+ else
18
+ raise ArgumentError, "Code block must be given" unless compute
19
+ instance_variable_set(k, compute.call)
20
+ end
21
+ end
22
+
23
+ # Same as {#igetset}, but this one calls an attribute writer to store the computed
24
+ # value into the object.
25
+ # @param [Symbol | String] name
26
+ # @return [mixed] The result of +compute+.
27
+ # @see #igetset
28
+ def igetwrite(name, &compute)
29
+ if instance_variable_defined?(k = "@#{name}")
30
+ instance_variable_get(k)
31
+ else
32
+ raise ArgumentError, "Code block must be given" unless compute
33
+ send("#{name}=", compute.call)
34
+ end
35
+ end
36
+
37
+ # Require an attribute to be set, present, valid or not invalid.
38
+ #
39
+ # require_attr(:name) # Require not to be `.nil?`.
40
+ # require_attr(:obj, :valid) # Require to be `.valid`.
41
+ # require_attr(:items, :present?) # Require to be `.present?`.
42
+ # require_attr(:items, :not_empty?) # Require not to be `.empty?`.
43
+ #
44
+ # @param name [Symbol | String]
45
+ # @param predicate [Symbol | String]
46
+ # @return [mixed] Attribute value.
47
+ # @raise [RuntimeError]
48
+ def require_attr(name, predicate = :not_nil?)
49
+ # Declare in the scope.
50
+ m = nil
51
+
52
+ # `check` is a function returning `true` if the value is good.
53
+ m, verb, check = if ((sp = predicate.to_s).start_with? "not_")
54
+ [
55
+ sp[4..-1],
56
+ "must not",
57
+ -> (v) { !v.public_send(m) },
58
+ ]
59
+ else
60
+ [
61
+ sp,
62
+ "must",
63
+ -> (v) { v.public_send(m) },
64
+ ]
65
+ end
66
+
67
+ raise ArgumentError, "Invalid predicate: #{predicate.inspect}" if m.empty?
68
+
69
+ # NOTE: Shorten the error backtrace to the minimum.
70
+
71
+ # Get and check the value.
72
+ v = send(name)
73
+ check.(v) or raise "Attribute `#{name}` #{verb} be #{m.chomp('?')}: #{v.inspect}"
74
+
75
+ v
76
+ end
77
+ end # InstanceMethods
78
+ end
@@ -0,0 +1,4 @@
1
+
2
+ module AttrMagic
3
+ VERSION = "0.1.2"
4
+ end
data/lib/attr_magic.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ "LODoc"
4
+
5
+ # Ease lazy attribute implementation to the owner class.
6
+ #
7
+ # = Usage
8
+ #
9
+ # class Klass
10
+ # AttrMagic.load(self)
11
+ # end
12
+ #
13
+ # = Features
14
+ #
15
+ # == Implement a lazy attribute reader
16
+ #
17
+ # class Person
18
+ # …
19
+ # attr_writer :full_name
20
+ #
21
+ # def full_name
22
+ # igetset(__method__) { [first_name, last_name].compact.join(" ").strip }
23
+ # end
24
+ # end
25
+ #
26
+ # See {InstanceMethods#igetset}, {InstanceMethods#igetwrite}.
27
+ #
28
+ # == Validate an attribute
29
+ #
30
+ # class Person
31
+ # …
32
+ # def full_name
33
+ # igetset(__method__) do
34
+ # #require_attr :first_name # Will check for `nil` only.
35
+ # require_attr :first_name, :present?
36
+ # #require_attr :first_name, :not_blank? # Also possible.
37
+ # [first_name, last_name].compact.join(" ").strip
38
+ # end
39
+ # end
40
+ # end
41
+ #
42
+ # See {InstanceMethods#require_attr}.
43
+ module AttrMagic
44
+ # Load the feature into +owner+.
45
+ # @param owner [Class]
46
+ def self.load(owner)
47
+ return if owner < InstanceMethods
48
+ owner.send(:include, InstanceMethods)
49
+ owner.class_eval { private :igetset, :igetwrite, :require_attr }
50
+ end
51
+ end
52
+
53
+ # Load all.
54
+ Dir[File.expand_path("attr_magic/**/*.rb", __dir__)].each { |fn| require fn }
data/libx/README.md ADDED
@@ -0,0 +1,4 @@
1
+
2
+ # Inline portions of RSpecMagic
3
+
4
+ See [dadooda/rspec_magic](https://github.com/dadooda/rspec_magic).
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ "LODoc"
4
+
5
+ module RSpecMagic
6
+ # Shared configuration.
7
+ module Config
8
+ class << self
9
+ attr_writer :spec_path
10
+
11
+ # @return [String]
12
+ def spec_path
13
+ @spec_path || raise("`#{__method__}` must be configured")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ "LODoc"
4
+
5
+ module RSpecMagic; module Stable
6
+ # Method alias matcher.
7
+ # Originally from https://gist.github.com/1950961, but heavily reworked consistency-wise.
8
+ #
9
+ # = Usage
10
+ #
11
+ # describe User do
12
+ # it { is_expected.to alias_method(:admin?, :is_admin) }
13
+ # end
14
+ module AliasMethod
15
+ end
16
+
17
+ # Activate.
18
+ defined?(RSpec) and RSpec::Matchers.define(:alias_method) do |new_name, old_name|
19
+ match do |subject|
20
+ expect(subject.method(new_name)).to eq subject.method(old_name)
21
+ end
22
+
23
+ description do
24
+ "have #{new_name.inspect} aliased to #{old_name.inspect}"
25
+ end
26
+ end
27
+ end; end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ "LODoc"
4
+
5
+ module RSpecMagic; module Stable
6
+ # Create a self-descriptive "when …" context with one or more +let+ variables defined.
7
+ #
8
+ # = Usage
9
+ #
10
+ # The following:
11
+ #
12
+ # context_when name: "Joe", age: 25 do
13
+ # …
14
+ # end
15
+ #
16
+ # is identical to:
17
+ #
18
+ # context "when { name: \"Joe\", age: 25 }" do
19
+ # let(:name) { "Joe" }
20
+ # let(:age) { 25 }
21
+ # …
22
+ # end
23
+ #
24
+ # = Features
25
+ #
26
+ # Prepend +x+ to +context_when+ to exclude it:
27
+ #
28
+ # xcontext_when … do
29
+ # …
30
+ # end
31
+ #
32
+ # ----
33
+ #
34
+ # Define a custom formatter via +_context_when_formatter+:
35
+ #
36
+ # context "…" do
37
+ # def self._context_when_formatter(h)
38
+ # "when #{h.to_json}"
39
+ # end
40
+ #
41
+ # …
42
+ # end
43
+ module ContextWhen
44
+ # Default formatter for {#context_when}. Redefine at the context level if needed.
45
+ #
46
+ # describe "…" do
47
+ # def self._context_when_formatter(h)
48
+ # # Your custom formatter here.
49
+ # h.to_json
50
+ # end
51
+ #
52
+ # context_when … do
53
+ # …
54
+ # end
55
+ # end
56
+ # @param [Hash] h
57
+ # @return [String]
58
+ def _context_when_formatter(h)
59
+ # Extract labels for Proc arguments, if any.
60
+ labels = {}
61
+ h.each do |k, v|
62
+ if v.is_a? Proc
63
+ begin
64
+ labels[k] = h.fetch(lk = "#{k}_label".to_sym)
65
+ h.delete(lk)
66
+ rescue KeyError
67
+ raise ArgumentError, "`#{k}` is a `Proc`, `#{k}_label` must be given"
68
+ end
69
+ end
70
+ end
71
+
72
+ pcs = h.map do |k, v|
73
+ [
74
+ k.is_a?(Symbol) ? "#{k}:" : "#{k.inspect} =>",
75
+ v.is_a?(Proc) ? labels[k] : v.inspect,
76
+ ].join(" ")
77
+ end
78
+
79
+ "when { " + pcs.join(", ") + " }"
80
+ end
81
+
82
+ # Create a context.
83
+ # @param [Hash] h
84
+ def context_when(h, &block)
85
+ context _context_when_formatter(h) do
86
+ h.each do |k, v|
87
+ if v.is_a? Proc
88
+ let(k, &v)
89
+ else
90
+ # Generic scalar value.
91
+ let(k) { v }
92
+ end
93
+ end
94
+ class_eval(&block)
95
+ end
96
+ end
97
+
98
+ # Create a temporarily excluded context.
99
+ def xcontext_when(h, &block)
100
+ xcontext _context_when_formatter(h) { class_eval(&block) }
101
+ end
102
+ end
103
+
104
+ # Activate.
105
+ defined?(RSpec) and RSpec.configure do |config|
106
+ config.extend ContextWhen
107
+ end
108
+ end; end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ "LODoc"
4
+
5
+ module RSpecMagic; module Stable
6
+ # Define methods to manage a set of custom +let+ variables which act as a distinct collection.
7
+ #
8
+ # RSpec.describe SomeKlass do
9
+ # use_letset :let_a, :attrs # (1)
10
+ # use_letset :let_p, :params # (2)
11
+ # ...
12
+ #
13
+ # In above examples, (1) provides our suite with:
14
+ #
15
+ # def self.let_a(let, &block)
16
+ # def attrs(include: [])
17
+ #
18
+ # , thus this now becomes possible:
19
+ #
20
+ # describe "attrs" do
21
+ # let_a(:name) { "Joe" }
22
+ # let_a(:age) { 25 }
23
+ # let(:gender) { :male }
24
+ # it do
25
+ # expect(name).to eq "Joe"
26
+ # expect(age).to eq 25
27
+ # expect(attrs).to eq(name: "Joe", age: 25)
28
+ # expect(attrs(include: [:gender])).to eq(name: "Joe", age: 25, gender: :male)
29
+ # end
30
+ # end
31
+ #
32
+ # By not providing a block it's possible to <b>declare</b> a custom <tt>let</tt> variable
33
+ # and be able to redefine it later via regular <tt>let</tt>. This will work:
34
+ #
35
+ # describe "declarative (no block) usage" do
36
+ # let_a(:name)
37
+ #
38
+ # subject { attrs }
39
+ #
40
+ # context "when no other `let` value" do
41
+ # it { is_expected.to eq({}) }
42
+ # end
43
+ #
44
+ # context "when `let`" do
45
+ # let(:name) { "Joe" }
46
+ # it { is_expected.to eq(name: "Joe") }
47
+ # end
48
+ # end
49
+ #
50
+ module UseLetset
51
+ # Define the collection.
52
+ # @param let_method [Symbol]
53
+ # @param collection_let [Symbol]
54
+ # @return [void]
55
+ def use_letset(let_method, collection_let)
56
+ keys_m = "_#{collection_let}_keys".to_sym
57
+
58
+ # See "Implementation notes" on failed implementation of "collection only" mode.
59
+
60
+ # E.g. "_data_keys" or something.
61
+ define_singleton_method(keys_m) do
62
+ if instance_variable_defined?(k = "@#{keys_m}")
63
+ instance_variable_get(k)
64
+ else
65
+ # Start by copying superclass's known vars or default to `[]`.
66
+ instance_variable_set(k, (superclass.send(keys_m).dup rescue []))
67
+ end
68
+ end
69
+
70
+ define_singleton_method let_method, ->(k, &block) do
71
+ (send(keys_m) << k).uniq!
72
+ # Create a `let` variable unless it's a declaration call (`let_a(:name)`).
73
+ let(k, &block) if block
74
+ end
75
+
76
+ define_method(collection_let) do
77
+ {}.tap do |h|
78
+ self.class.send(keys_m).each do |k|
79
+ h[k] = public_send(k) if respond_to?(k)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ # Activate.
87
+ defined?(RSpec) and RSpec.configure do |config|
88
+ config.extend UseLetset
89
+ end
90
+ end; end
91
+
92
+ #
93
+ # Implementation notes:
94
+ #
95
+ # * There was once an idea to support `use_letset` in "collection only" mode. Say, `let_a` appends
96
+ # to `attrs`, but doesn't publish a let variable. This change IS COMPLETELY NOT IN LINE with
97
+ # RSpec design. Let variables are methods and the collection is built by probing for those
98
+ # methods. "Collection only" would require a complete redesign. It's easier to implement another
99
+ # helper method for that, or, even better, do it with straight Ruby right in the test where
100
+ # needed. The need for "collection only" mode is incredibly rare, say, specific serializer
101
+ # tests.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ "LODoc"
4
+
5
+ module RSpecMagic; module Stable
6
+ # Provide an automatic <tt>let</tt> variable which contains a method name discovered from
7
+ # its string representation.
8
+ #
9
+ # describe "instance methods" do
10
+ # use_method_discovery :m
11
+ #
12
+ # describe "#name" do
13
+ # it { expect(m).to eq :name }
14
+ # end
15
+ #
16
+ # describe "#surname" do
17
+ # it { expect(m).to eq :surname }
18
+ # end
19
+ # end
20
+ #
21
+ module UseMethodDiscovery
22
+ # Enable the discovery mechanics.
23
+ # @param [Symbol] method_let
24
+ def use_method_discovery(method_let)
25
+ # This context and all sub-contexts will respond to A and return B. "Signature" is based on
26
+ # invocation arguments which can vary as we use the feature more intensively. Signature method
27
+ # is the same, thus it shadows higher level definitions completely.
28
+ signature = { method_let: method_let }
29
+ define_singleton_method(:_umd_signature) { signature }
30
+
31
+ let(method_let) do
32
+ # NOTE: `self.class` responds to signature method, no need to probe and rescue.
33
+ if (sig = (klass = self.class)._umd_signature) != signature
34
+ raise "`#{method_let}` is shadowed by `#{sig.fetch(:method_let)}` in this context"
35
+ end
36
+
37
+ # NOTE: Better not `return` from the loop to keep it debuggable in case logic changes.
38
+ found = nil
39
+ while (klass._umd_signature rescue nil) == signature
40
+ found = self.class.send(:_use_method_discovery_parser, klass.description.to_s) and break
41
+ klass = klass.superclass
42
+ end
43
+
44
+ found or raise "No method-like descriptions found to use as `#{method_let}`"
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # The parser used by {.use_method_discovery}.
51
+ # @param [String] input
52
+ # @return [Symbol] Method name, if parsed okay.
53
+ # @return [nil] If input isn't method-like.
54
+ def _use_method_discovery_parser(input)
55
+ if (mat = input.match(/^(?:(?:#|\.|::)(\w+(?:\?|!|=|)|\[\])|(?:DELETE|GET|PUT|POST) (\w+))$/))
56
+ (mat[1] || mat[2]).to_sym
57
+ end
58
+ end
59
+ end # module
60
+
61
+ # Activate.
62
+ defined?(RSpec) and RSpec.configure do |config|
63
+ config.extend UseMethodDiscovery
64
+ end
65
+ end; end
@@ -0,0 +1,2 @@
1
+
2
+ Dir[File.expand_path("../stable/**/*.rb", __FILE__)].each { |fn| require fn }
@@ -0,0 +1,9 @@
1
+
2
+ # Load public features.
3
+ require_relative "rspec_magic/config"
4
+
5
+ # # Load a full set of stable features.
6
+ # require_relative "rspec_magic/alias_method"
7
+ # require_relative "rspec_magic/context_when"
8
+ # require_relative "rspec_magic/use_letset"
9
+ # require_relative "rspec_magic/use_method_discovery"
@@ -0,0 +1,140 @@
1
+
2
+ RSpec.describe AttrMagic do
3
+ use_method_discovery :m
4
+
5
+ describe "#iget*" do
6
+ let(:klass) do
7
+ feature = described_class
8
+ Class.new do
9
+ feature.load(self)
10
+
11
+ attr_writer :c
12
+
13
+ def a
14
+ igetset(:a) do
15
+ calls << :a_block
16
+ false
17
+ end
18
+ end
19
+
20
+ def b
21
+ igetset(:b) do
22
+ calls << :b_block
23
+ nil
24
+ end
25
+ end
26
+
27
+ def c
28
+ igetwrite(:c) do
29
+ calls << :c_block
30
+ "c"
31
+ end
32
+ end
33
+
34
+ def c=(value)
35
+ calls << :c_writer
36
+ @c = value + "!" # Custom modification by the writer.
37
+ end
38
+
39
+ def calls
40
+ @log ||= []
41
+ end
42
+ end
43
+ end
44
+
45
+ describe "#igetset" do
46
+ it "generally works" do
47
+ obj = klass.new
48
+ expect(obj.calls).to eq []
49
+ expect(obj.a).to be false
50
+ expect(obj.a).to be false
51
+ expect(obj.calls).to eq [:a_block]
52
+
53
+ obj = klass.new
54
+ expect(obj.calls).to eq []
55
+ expect(obj.b).to be nil
56
+ expect(obj.b).to be nil
57
+ expect(obj.calls).to eq [:b_block]
58
+ end
59
+ end # describe "#igetset"
60
+
61
+ describe "#igetwrite" do
62
+ it "generally works" do
63
+ obj = klass.new
64
+ expect(obj.calls).to eq []
65
+ expect(obj.c).to eq "c!"
66
+ expect(obj.c).to eq "c!"
67
+ expect(obj.c).to eq "c!"
68
+ expect(obj.calls).to eq [:c_block, :c_writer]
69
+ end
70
+ end # describe "#igetwrite"
71
+ end # describe "iget*"
72
+
73
+ describe "#require_attr" do
74
+ let(:klass) do
75
+ feature = described_class
76
+ Class.new do
77
+ feature.load(self)
78
+
79
+ attr_accessor :a
80
+
81
+ def initialize(attrs = {})
82
+ attrs.each { |k, v| public_send("#{k}=", v) }
83
+ end
84
+ end
85
+ end
86
+
87
+ let(:obj) { klass.new(a: value) }
88
+
89
+ subject { obj.send(m, :a, predicate) }
90
+
91
+ context "when valid arguments" do
92
+ # NOTE: We don't nest contexts and provide a linear sequence of [value, predicate] for
93
+ # smarter reporting.
94
+ context_when value: nil do
95
+ subject { obj.send(m, :a) }
96
+ it { expect { subject }.to raise_error(RuntimeError, /attribute.+`a`.+not.+nil: nil/i) }
97
+ end
98
+
99
+ context_when value: nil, predicate: "not_nil?" do
100
+ it { expect { subject }.to raise_error(RuntimeError, /attribute.+`a`.+not.+nil: nil/i) }
101
+ end
102
+
103
+ context_when value: [], predicate: :not_empty? do
104
+ it { expect { subject }.to raise_error(RuntimeError, /attribute.+`a`.+not.+empty: \[\]/i) }
105
+ end
106
+
107
+ context_when value: 1, predicate: :odd? do
108
+ it { is_expected.to eq 1 }
109
+ end
110
+
111
+ context_when value: 1, predicate: :even? do
112
+ it { expect { subject }.to raise_error(RuntimeError, /attribute.+`a`.+even: 1/i) }
113
+ end
114
+
115
+ context_when value: [], predicate: :valid do
116
+ let(:a) { double "a" }
117
+ it do
118
+ expect(obj).to receive(:a).and_return(a)
119
+ expect(a).to receive(:valid).and_return(false)
120
+ expect { subject }.to raise_error(RuntimeError, /must be valid/i)
121
+ end
122
+ end
123
+
124
+ # NOTE: This is a valid case in a duck-typed language which Ruby is.
125
+ context_when value: [], predicate: "joyful?" do
126
+ it { expect { subject }.to raise_error(NoMethodError, /joyful\?/) }
127
+ end
128
+ end # context "when valid arguments"
129
+
130
+ context "when invalid arguments" do
131
+ context_when value: [], predicate: "" do
132
+ it { expect { subject }.to raise_error(ArgumentError, /invalid predicate/i) }
133
+ end
134
+
135
+ context_when value: [], predicate: "not_" do
136
+ it { expect { subject }.to raise_error(ArgumentError, /invalid predicate/i) }
137
+ end
138
+ end
139
+ end # describe "#require_attr"
140
+ end # describe
@@ -0,0 +1,6 @@
1
+
2
+ # Must go before all.
3
+ require_relative "support/simplecov"
4
+
5
+ # Load support files.
6
+ Dir[File.expand_path("support/**/*.rb", __dir__)].each { |fn| require fn }
@@ -0,0 +1,2 @@
1
+
2
+ require "its"
@@ -0,0 +1,2 @@
1
+
2
+ require_relative "../../libx/rspec_magic/stable"
@@ -0,0 +1,2 @@
1
+
2
+ require "attr_magic"
@@ -0,0 +1,11 @@
1
+
2
+ #
3
+ # See https://github.com/simplecov-ruby/simplecov#getting-started.
4
+ #
5
+
6
+ require "simplecov"
7
+
8
+ SimpleCov.start do
9
+ add_filter "libx/"
10
+ add_filter "spec/"
11
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attr_magic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Alex Fortuna
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-01-28 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - fortunadze@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".gitignore"
21
+ - ".rspec"
22
+ - ".yardopts"
23
+ - Gemfile
24
+ - Gemfile.lock
25
+ - MIT-LICENSE
26
+ - README-ru.md
27
+ - README.md
28
+ - Rakefile
29
+ - attr_magic.gemspec
30
+ - lib/attr_magic.rb
31
+ - lib/attr_magic/instance_methods.rb
32
+ - lib/attr_magic/version.rb
33
+ - libx/README.md
34
+ - libx/rspec_magic.rb
35
+ - libx/rspec_magic/config.rb
36
+ - libx/rspec_magic/stable.rb
37
+ - libx/rspec_magic/stable/alias_method.rb
38
+ - libx/rspec_magic/stable/context_when.rb
39
+ - libx/rspec_magic/stable/use_letset.rb
40
+ - libx/rspec_magic/stable/use_method_discovery.rb
41
+ - spec/lib/attr_magic_spec.rb
42
+ - spec/spec_helper.rb
43
+ - spec/support/its.rb
44
+ - spec/support/rspec_magic.rb
45
+ - spec/support/self.rb
46
+ - spec/support/simplecov.rb
47
+ homepage: https://github.com/dadooda/attr_magic
48
+ licenses:
49
+ - MIT
50
+ metadata: {}
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubyforge_project:
67
+ rubygems_version: 2.5.2
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: The tools to ease lazy attribute implementation
71
+ test_files: []