attr_magic 0.1.2

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 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: []