cattri 0.1.3 → 0.2.0

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +34 -0
  3. data/.gitignore +72 -0
  4. data/.rubocop.yml +6 -3
  5. data/CHANGELOG.md +41 -0
  6. data/Gemfile +12 -0
  7. data/README.md +163 -151
  8. data/Steepfile +6 -0
  9. data/bin/console +33 -0
  10. data/bin/setup +8 -0
  11. data/cattri.gemspec +5 -5
  12. data/lib/cattri/attribute.rb +119 -155
  13. data/lib/cattri/attribute_compiler.rb +104 -0
  14. data/lib/cattri/attribute_options.rb +183 -0
  15. data/lib/cattri/attribute_registry.rb +155 -0
  16. data/lib/cattri/context.rb +124 -106
  17. data/lib/cattri/context_registry.rb +36 -0
  18. data/lib/cattri/deferred_attributes.rb +73 -0
  19. data/lib/cattri/dsl.rb +54 -0
  20. data/lib/cattri/error.rb +17 -90
  21. data/lib/cattri/inheritance.rb +35 -0
  22. data/lib/cattri/initializer_patch.rb +37 -0
  23. data/lib/cattri/internal_store.rb +104 -0
  24. data/lib/cattri/introspection.rb +56 -49
  25. data/lib/cattri/version.rb +3 -1
  26. data/lib/cattri.rb +38 -99
  27. data/sig/lib/cattri/attribute.rbs +105 -0
  28. data/sig/lib/cattri/attribute_compiler.rbs +61 -0
  29. data/sig/lib/cattri/attribute_options.rbs +150 -0
  30. data/sig/lib/cattri/attribute_registry.rbs +95 -0
  31. data/sig/lib/cattri/context.rbs +130 -0
  32. data/sig/lib/cattri/context_registry.rbs +31 -0
  33. data/sig/lib/cattri/deferred_attributes.rbs +53 -0
  34. data/sig/lib/cattri/dsl.rbs +55 -0
  35. data/sig/lib/cattri/error.rbs +28 -0
  36. data/sig/lib/cattri/inheritance.rbs +21 -0
  37. data/sig/lib/cattri/initializer_patch.rbs +26 -0
  38. data/sig/lib/cattri/internal_store.rbs +75 -0
  39. data/sig/lib/cattri/introspection.rbs +61 -0
  40. data/sig/lib/cattri/types.rbs +19 -0
  41. data/sig/lib/cattri/visibility.rbs +55 -0
  42. data/sig/lib/cattri.rbs +37 -0
  43. data/spec/cattri/attribute_compiler_spec.rb +179 -0
  44. data/spec/cattri/attribute_options_spec.rb +267 -0
  45. data/spec/cattri/attribute_registry_spec.rb +257 -0
  46. data/spec/cattri/attribute_spec.rb +297 -0
  47. data/spec/cattri/context_registry_spec.rb +45 -0
  48. data/spec/cattri/context_spec.rb +346 -0
  49. data/spec/cattri/deferred_attrributes_spec.rb +117 -0
  50. data/spec/cattri/dsl_spec.rb +69 -0
  51. data/spec/cattri/error_spec.rb +37 -0
  52. data/spec/cattri/inheritance_spec.rb +60 -0
  53. data/spec/cattri/initializer_patch_spec.rb +35 -0
  54. data/spec/cattri/internal_store_spec.rb +139 -0
  55. data/spec/cattri/introspection_spec.rb +90 -0
  56. data/spec/cattri/visibility_spec.rb +68 -0
  57. data/spec/cattri_spec.rb +54 -0
  58. data/spec/simplecov_helper.rb +21 -0
  59. data/spec/spec_helper.rb +16 -0
  60. metadata +79 -6
  61. data/lib/cattri/attribute_definer.rb +0 -143
  62. data/lib/cattri/class_attributes.rb +0 -277
  63. data/lib/cattri/instance_attributes.rb +0 -276
  64. data/sig/cattri.rbs +0 -4
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cattri::AttributeOptions do
6
+ describe ".validate_expose!" do
7
+ subject(:instance) { described_class.new(:attr) }
8
+
9
+ it "returns the valid option provided" do
10
+ expect(described_class.validate_expose!(:read)).to eq(:read)
11
+ end
12
+
13
+ it "returns the valid option provided from a string" do
14
+ expect(described_class.validate_expose!("read")).to eq(:read)
15
+ end
16
+
17
+ it "raises on an invalid option" do
18
+ expect do
19
+ described_class.validate_expose!(:invalid)
20
+ end.to raise_error(Cattri::AttributeError, /Invalid expose option `:invalid`/)
21
+ end
22
+ end
23
+
24
+ describe ".validate_visibility!" do
25
+ subject(:instance) { described_class.new(:attr) }
26
+
27
+ it "returns the valid option provided" do
28
+ expect(described_class.validate_visibility!(:public)).to eq(:public)
29
+ end
30
+
31
+ it "returns the valid option provided from a string" do
32
+ expect(described_class.validate_visibility!("public")).to eq(:public)
33
+ end
34
+
35
+ it "raises on an invalid option" do
36
+ expect do
37
+ described_class.validate_visibility!(:invalid)
38
+ end.to raise_error(Cattri::AttributeError, /Invalid visibility `:invalid`/)
39
+ end
40
+ end
41
+
42
+ describe "#initialize" do
43
+ let(:name) { :my_attribute }
44
+
45
+ it "sets @name to symbolized name" do
46
+ instance = described_class.new(name)
47
+ expect(instance.instance_variable_get(:@name)).to eq(:my_attribute)
48
+ end
49
+
50
+ context "with default options" do
51
+ subject(:instance) { described_class.new(name) }
52
+
53
+ it "sets @ivar to normalized default" do
54
+ expect(instance.instance_variable_get(:@ivar)).to eq(:@my_attribute)
55
+ end
56
+
57
+ it "sets @final to false" do
58
+ expect(instance.instance_variable_get(:@final)).to be(false)
59
+ end
60
+
61
+ it "sets @scope to :instance" do
62
+ expect(instance.instance_variable_get(:@scope)).to be(:instance)
63
+ end
64
+
65
+ it "sets @predicate to false" do
66
+ expect(instance.instance_variable_get(:@predicate)).to be(false)
67
+ end
68
+
69
+ it "sets @default using normalize_default" do
70
+ expect(instance.instance_variable_get(:@default).call).to eq(nil)
71
+ end
72
+
73
+ it "sets @transformer using normalize_transformer" do
74
+ transformer = instance.instance_variable_get(:@transformer)
75
+ expect(transformer).to respond_to(:call)
76
+ end
77
+
78
+ it "sets @expose using validate_expose!" do
79
+ expect(instance.instance_variable_get(:@expose)).to eq(:read_write)
80
+ end
81
+
82
+ it "sets @visibility using validate_visibility!" do
83
+ expect(instance.instance_variable_get(:@visibility)).to eq(:public)
84
+ end
85
+
86
+ it "freezes the instance" do
87
+ expect(instance).to be_frozen
88
+ end
89
+ end
90
+
91
+ context "with provided options" do
92
+ let(:ivar) { :custom_ivar }
93
+ let(:final) { true }
94
+ let(:scope) { :class }
95
+ let(:predicate) { true }
96
+ let(:default) { :custom_default }
97
+ let(:transformer) { ->(val) { val } }
98
+ let(:expose) { :read }
99
+ let(:visibility) { :private }
100
+
101
+ subject(:instance) do
102
+ described_class.new(
103
+ name,
104
+ ivar: ivar,
105
+ final: final,
106
+ scope: scope,
107
+ predicate: predicate,
108
+ default: default,
109
+ transformer: transformer,
110
+ expose: expose,
111
+ visibility: visibility
112
+ )
113
+ end
114
+
115
+ it "assigns the custom ivar" do
116
+ expect(instance.instance_variable_get(:@ivar)).to eq(:@custom_ivar)
117
+ end
118
+
119
+ it "assigns the custom final value" do
120
+ expect(instance.instance_variable_get(:@final)).to eq(true)
121
+ end
122
+
123
+ it "assigns the custom scope value" do
124
+ expect(instance.instance_variable_get(:@scope)).to eq(:class)
125
+ end
126
+
127
+ it "assigns the custom predicate value" do
128
+ expect(instance.instance_variable_get(:@predicate)).to eq(true)
129
+ end
130
+
131
+ it "assigns the custom default value" do
132
+ expect(instance.instance_variable_get(:@default).call).to eq(:custom_default)
133
+ end
134
+
135
+ it "assigns the custom transformer" do
136
+ expect(instance.instance_variable_get(:@transformer)).to eq(transformer)
137
+ end
138
+
139
+ it "assigns the custom expose value" do
140
+ expect(instance.instance_variable_get(:@expose)).to eq(:read)
141
+ end
142
+
143
+ it "assigns the custom visibility value" do
144
+ expect(instance.instance_variable_get(:@visibility)).to eq(:private)
145
+ end
146
+ end
147
+ end
148
+
149
+ describe "#[]" do
150
+ subject(:instance) { described_class.new(:attr) }
151
+
152
+ it "returns for known options" do
153
+ expect(instance[:name]).to eq(:attr)
154
+ end
155
+
156
+ it "returns nil for unknown options" do
157
+ expect(instance[:unknown]).to be_nil
158
+ end
159
+
160
+ it "converts the key provided to a symbol" do
161
+ expect(instance["ivar"]).to eq(:@attr)
162
+ end
163
+ end
164
+
165
+ describe "#to_h" do
166
+ it "returns a hash representation of the options" do
167
+ instance = described_class.new(:attr, ivar: :custom_ivar)
168
+ hash = instance.to_h
169
+
170
+ expect(hash).to be_frozen
171
+ expect(hash).to include(
172
+ name: :attr,
173
+ ivar: :@custom_ivar,
174
+ final: false,
175
+ scope: :instance,
176
+ predicate: false,
177
+ expose: :read_write,
178
+ visibility: :public
179
+ )
180
+
181
+ expect(hash[:default].call).to be_nil
182
+ expect(hash[:transformer].call(123)).to be(123)
183
+ end
184
+ end
185
+
186
+ describe "#normalize_ivar" do
187
+ subject(:instance) { described_class.new(:attr) }
188
+
189
+ [
190
+ [nil, :@attr],
191
+ %i[@custom @custom],
192
+ ["@_custom", :@_custom],
193
+ %i[@__custom @__custom]
194
+ ].each do |(ivar, expected)|
195
+ it "returns #{expected.inspect} for '#{ivar}'" do
196
+ expect(instance.send(:normalize_ivar, ivar)).to eq(expected)
197
+ end
198
+ end
199
+ end
200
+
201
+ describe "#normalize_default" do
202
+ subject(:instance) { described_class.new(:attr) }
203
+
204
+ it "returns existing callable unchanged" do
205
+ fn = -> { :ok }
206
+ default = instance.send(:normalize_default, fn)
207
+
208
+ expect(default).to be_a(Proc)
209
+ expect(default.call).to eq(fn.call)
210
+ end
211
+
212
+ it "wraps immutable value in lambda" do
213
+ default = instance.send(:normalize_default, :sym)
214
+
215
+ expect(default).to be_a(Proc)
216
+ expect(default.call).to eq(:sym)
217
+ end
218
+
219
+ it "wraps mutable values and duplicates them" do
220
+ default = instance.send(:normalize_default, [1, 2])
221
+ v1 = default.call
222
+ v2 = default.call
223
+
224
+ expect(v1).to eq([1, 2])
225
+ expect(v1).not_to be(v2)
226
+ end
227
+ end
228
+
229
+ describe "#normalize_transformer" do
230
+ subject(:instance) { described_class.new(:attr) }
231
+
232
+ it "returns kwargs if no positional args provided" do
233
+ expect(instance.transformer.call(a: 2)).to eq({ a: 2 })
234
+ end
235
+
236
+ it "returns single value if one positional arg provided" do
237
+ expect(instance.transformer.call("only")).to eq("only")
238
+ end
239
+
240
+ it "returns all positional args if multiple provided without kwargs" do
241
+ expect(instance.transformer.call(1, 2, 3)).to eq([1, 2, 3])
242
+ end
243
+
244
+ it "returns positional args and kwargs" do
245
+ expect(instance.transformer.call(1, a: 2)).to eq([1, { a: 2 }])
246
+ end
247
+ end
248
+
249
+ describe "#validate_scope!" do
250
+ subject(:instance) { described_class.new(:attr) }
251
+
252
+ [
253
+ [nil, :instance],
254
+ %i[instance instance],
255
+ %i[class class]
256
+ ].each do |(scope, expected)|
257
+ it "returns :#{expected} when provided :#{scope}" do
258
+ expect(instance.send(:validate_scope!, scope)).to eq(expected)
259
+ end
260
+ end
261
+
262
+ it "raises an error when provided an unknown scope" do
263
+ expect { instance.send(:validate_scope!, :invalid) }
264
+ .to raise_error(Cattri::AttributeError, /Invalid scope `:invalid`/)
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cattri::AttributeRegistry do
6
+ let(:parent_klass) do
7
+ Class.new do
8
+ include Cattri
9
+ end
10
+ end
11
+
12
+ let(:klass) do
13
+ Class.new(parent_klass) do
14
+ include Cattri
15
+ end
16
+ end
17
+
18
+ let(:context) { Cattri::Context.new(klass) }
19
+ let(:defer) { false }
20
+
21
+ subject(:registry) { Cattri::AttributeRegistry.new(context) }
22
+
23
+ before do
24
+ allow(context).to receive(:defer_definitions?).and_return(defer)
25
+ end
26
+
27
+ describe "#initialize" do
28
+ it "instantiates an AttributeRegistry instance with context" do
29
+ expect(registry).to be_a(Cattri::AttributeRegistry)
30
+ expect(registry.context).to eq(context)
31
+ end
32
+ end
33
+
34
+ describe "#registered_attributes" do
35
+ it "returns a Hash" do
36
+ expect(context.send(:__cattri_defined_methods)).to be_a(Hash)
37
+ end
38
+
39
+ it "returns the defined attributes" do
40
+ registry.define_attribute(:enabled, true)
41
+
42
+ expect(registry.registered_attributes).to be_a(Hash)
43
+ expect(registry.registered_attributes[:enabled]).to be_a(Cattri::Attribute)
44
+ end
45
+ end
46
+
47
+ describe "#defined_attributes" do
48
+ let(:parent_attribute) { Cattri::Attribute.new(:parent, defined_in: context.target) }
49
+ let(:attribute) { Cattri::Attribute.new(:enabled, defined_in: context.target) }
50
+
51
+ before do
52
+ parent_klass.send(:attribute_registry)
53
+ .instance_variable_set(
54
+ :@__cattri_registered_attributes,
55
+ { parent_attribute.name => parent_attribute }
56
+ )
57
+
58
+ registry.instance_variable_set(:@__cattri_registered_attributes, { attribute.name => attribute })
59
+ end
60
+
61
+ it "returns only instance-level attributes by default" do
62
+ expect(registry.defined_attributes.values).to eq([attribute])
63
+ end
64
+
65
+ it "returns all attributes, instance- and ancestor-level, when setting with_ancestors: true" do
66
+ expect(registry.defined_attributes(with_ancestors: true).values).to eq([parent_attribute, attribute])
67
+ end
68
+ end
69
+
70
+ describe "#fetch_attribute" do
71
+ it "fetches a defined attribute" do
72
+ registry.define_attribute(:enabled, true)
73
+ attribute = registry.fetch_attribute(:enabled)
74
+
75
+ expect(attribute).to be_a(Cattri::Attribute)
76
+ expect(attribute.name).to eq(:enabled)
77
+ expect(attribute.defined_in).to eq(context.target)
78
+ end
79
+
80
+ it "returns nil on undefined attributes" do
81
+ attribute = registry.fetch_attribute(:undefined)
82
+ expect(attribute).to be_nil
83
+ end
84
+ end
85
+
86
+ describe "#fetch_attribute!" do
87
+ it "fetches a defined attribute" do
88
+ registry.define_attribute(:enabled, true)
89
+ attribute = registry.fetch_attribute!(:enabled)
90
+
91
+ expect(attribute).to be_a(Cattri::Attribute)
92
+ expect(attribute.name).to eq(:enabled)
93
+ expect(attribute.defined_in).to eq(context.target)
94
+ end
95
+
96
+ it "raises an error on undefined attributes" do
97
+ expect { registry.fetch_attribute!(:undefined) }
98
+ .to raise_error(Cattri::AttributeError, "Attribute :undefined has not been defined")
99
+ end
100
+ end
101
+
102
+ describe "#define_attribute" do
103
+ it "defines an attribute" do
104
+ registry.define_attribute(:enabled, true)
105
+ attribute = registry.fetch_attribute(:enabled)
106
+
107
+ expect(attribute).to be_a(Cattri::Attribute)
108
+ expect(attribute.name).to eq(:enabled)
109
+ expect(attribute.defined_in).to eq(context.target)
110
+ end
111
+
112
+ it "raises an error on duplicate attribute definitions" do
113
+ registry.define_attribute(:enabled, true)
114
+
115
+ expect { registry.define_attribute(:enabled, true) }
116
+ .to raise_error(Cattri::AttributeError, "Attribute :enabled has already been defined")
117
+ end
118
+ end
119
+
120
+ describe "#copy_attributes_to" do
121
+ let(:target_klass) do
122
+ Class.new do
123
+ include Cattri
124
+ end
125
+ end
126
+
127
+ let!(:target_registry) { target_klass.send(:attribute_registry) }
128
+ let(:target_context) { Cattri::Context.new(target_klass) }
129
+
130
+ it "copies final class attributes and sets their values" do
131
+ registry.define_attribute(:enabled, "yes", scope: :class, final: true)
132
+ registry.context.target.cattri_variable_set(:@enabled, "yes")
133
+
134
+ registry.copy_attributes_to(target_context)
135
+
136
+ expect(target_klass.cattri_variable_get(:@enabled)).to eq("yes")
137
+ expect(target_registry.fetch_attribute(:enabled)).to be_a(Cattri::Attribute)
138
+ end
139
+
140
+ it "skips non-final or instance-level attributes" do
141
+ registry.define_attribute(:skipped1, "val", scope: :instance, final: true)
142
+ registry.define_attribute(:skipped2, "val", scope: :class, final: false)
143
+
144
+ expect do
145
+ registry.copy_attributes_to(target_context)
146
+ end.not_to(change { target_klass.send(:attribute_registry).defined_attributes })
147
+ end
148
+
149
+ it "restores original context after copying" do
150
+ original_context = registry.context
151
+ registry.copy_attributes_to(target_context)
152
+
153
+ expect(registry.context).to equal(original_context)
154
+ end
155
+ end
156
+
157
+ describe "#validate_unique!" do
158
+ context "when no attributes are registered" do
159
+ it "returns without raising" do
160
+ expect { registry.send(:validate_unique!, :enabled) }.not_to raise_error
161
+ end
162
+ end
163
+
164
+ context "when attribute exists in @__cattri_registered_attributes" do
165
+ before do
166
+ registry.define_attribute(:enabled, true)
167
+ end
168
+
169
+ it "raises an error on duplicate attribute definitions" do
170
+ expect { registry.send(:validate_unique!, :enabled) }
171
+ .to raise_error(Cattri::AttributeError, "Attribute :enabled has already been defined")
172
+ end
173
+ end
174
+
175
+ context "when @__cattri_registered_attributes is defined but attribute does not exist" do
176
+ before do
177
+ registry.define_attribute(:existing, true)
178
+ end
179
+
180
+ it "does not raise error for a different attribute" do
181
+ expect { registry.send(:validate_unique!, :something_else) }.not_to raise_error
182
+ end
183
+ end
184
+ end
185
+
186
+ describe "#register_attribute" do
187
+ let(:attribute) { Cattri::Attribute.new(:enabled, defined_in: context.target) }
188
+
189
+ before do
190
+ allow(registry).to receive(:defer_definition).and_return(nil)
191
+ allow(registry).to receive(:apply_definition!).and_return(nil)
192
+ end
193
+
194
+ it "registers the attribute in __defined_attributes" do
195
+ expect(registry.defined_attributes).to be_empty
196
+
197
+ registry.send(:register_attribute, attribute)
198
+
199
+ expect(registry.fetch_attribute(attribute.name)).to eq(attribute)
200
+ end
201
+
202
+ context "when attributes are not deferred" do
203
+ it "applies the attribute definition" do
204
+ registry.send(:register_attribute, attribute)
205
+
206
+ expect(registry).not_to have_received(:defer_definition)
207
+ expect(registry).to have_received(:apply_definition!).with(attribute)
208
+ end
209
+ end
210
+
211
+ context "when attributes are deferred" do
212
+ let(:defer) { true }
213
+
214
+ it "defers the attribute definition" do
215
+ registry.send(:register_attribute, attribute)
216
+
217
+ expect(registry).to have_received(:defer_definition).with(attribute)
218
+ expect(registry).not_to have_received(:apply_definition!)
219
+ end
220
+ end
221
+ end
222
+
223
+ describe "#defer_definition" do
224
+ let(:attribute) { Cattri::Attribute.new(:enabled, defined_in: context.target) }
225
+
226
+ before do
227
+ allow(context).to receive(:ensure_deferred_support!)
228
+ allow(context.target).to receive(:defer_attribute)
229
+ end
230
+
231
+ it "defers the attribute definition to context" do
232
+ registry.send(:defer_definition, attribute)
233
+
234
+ expect(context).to have_received(:ensure_deferred_support!)
235
+ expect(context.target).to have_received(:defer_attribute).with(attribute)
236
+ end
237
+ end
238
+
239
+ describe "#apply_definition!" do
240
+ let(:attribute) { Cattri::Attribute.new(:enabled, defined_in: context.target) }
241
+
242
+ it "calls Cattri::AttributeCompiler.define_accessor" do
243
+ allow(Cattri::AttributeCompiler).to receive(:define_accessor)
244
+
245
+ registry.send(:apply_definition!, attribute)
246
+
247
+ expect(Cattri::AttributeCompiler).to have_received(:define_accessor).with(attribute, context)
248
+ end
249
+
250
+ it "raises a Cattri::AttributeError when definition fails" do
251
+ allow(Cattri::AttributeCompiler).to receive(:define_accessor).and_raise(TypeError, "boom")
252
+
253
+ expect { registry.send(:apply_definition!, attribute) }
254
+ .to raise_error(Cattri::AttributeError, "Attribute #{attribute.name} could not be defined. Error: boom")
255
+ end
256
+ end
257
+ end