persistent_enum 1.2.4
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 +7 -0
- data/.circleci/config.yml +162 -0
- data/.gitignore +25 -0
- data/.rspec +1 -0
- data/.travis.yml +39 -0
- data/Appraisals +7 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +9 -0
- data/README.md +7 -0
- data/Rakefile +8 -0
- data/gemfiles/rails_5_0.gemfile +7 -0
- data/gemfiles/rails_5_1.gemfile +7 -0
- data/gemfiles/rails_5_2.gemfile +8 -0
- data/gemfiles/rails_6_0_beta.gemfile +8 -0
- data/lib/persistent_enum.rb +437 -0
- data/lib/persistent_enum/acts_as_enum.rb +230 -0
- data/lib/persistent_enum/railtie.rb +12 -0
- data/lib/persistent_enum/version.rb +5 -0
- data/persistent_enum.gemspec +36 -0
- data/spec/spec_helper.rb +108 -0
- data/spec/support/config/database.yml +12 -0
- data/spec/support/helpers/database_helper.rb +67 -0
- data/spec/unit/persistent_enum_spec.rb +758 -0
- metadata +237 -0
@@ -0,0 +1,758 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Style/Semicolon, Lint/MissingCopEnableDirective
|
4
|
+
|
5
|
+
require 'persistent_enum'
|
6
|
+
require 'byebug'
|
7
|
+
|
8
|
+
require_relative '../spec_helper'
|
9
|
+
|
10
|
+
RSpec.describe PersistentEnum, :database do
|
11
|
+
CONSTANTS = [:One, :Two, :Three, :Four].freeze
|
12
|
+
|
13
|
+
before(:context) do
|
14
|
+
DatabaseHelper.initialize_database
|
15
|
+
end
|
16
|
+
|
17
|
+
before(:each) do
|
18
|
+
if ActiveRecord::Base.logger
|
19
|
+
allow(ActiveRecord::Base.logger).to receive(:warn).and_call_original
|
20
|
+
else
|
21
|
+
ActiveRecord::Base.logger = spy(:logger)
|
22
|
+
end
|
23
|
+
initialize_test_models
|
24
|
+
end
|
25
|
+
|
26
|
+
after(:each) do
|
27
|
+
destroy_test_models
|
28
|
+
unless ActiveRecord::Base.logger.is_a?(Logger)
|
29
|
+
ActiveRecord::Base.logger = nil
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
shared_examples 'acts like an enum' do
|
34
|
+
# abstract :model
|
35
|
+
|
36
|
+
it 'looks up each value' do
|
37
|
+
CONSTANTS.each do |c|
|
38
|
+
e = model.value_of(c)
|
39
|
+
expect(e).to be_present
|
40
|
+
expect(e.enum_constant).to be_a(String)
|
41
|
+
expect(e.to_sym).to eq(c)
|
42
|
+
expect(e).to eq(model[e.ordinal])
|
43
|
+
expect(e).to eq(model.const_get(c.upcase))
|
44
|
+
expect(e).to be_frozen
|
45
|
+
expect(e.enum_constant).to be_frozen
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'returns all values from the cache' do
|
50
|
+
expect(model.values.map(&:to_sym)).to contain_exactly(*CONSTANTS)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
shared_examples 'acts like a persisted enum' do
|
55
|
+
# abstract :model
|
56
|
+
|
57
|
+
include_examples 'acts like an enum'
|
58
|
+
|
59
|
+
context 'a referring model' do
|
60
|
+
let(:foreign_name) { model.model_name.singular }
|
61
|
+
let(:foreign_key) { foreign_name + '_id' }
|
62
|
+
|
63
|
+
let(:other_model) do
|
64
|
+
foreign_name = foreign_name()
|
65
|
+
foreign_key_type = model.columns.detect { |x| x.name == 'id' }.sql_type
|
66
|
+
|
67
|
+
create_table = ->(t) {
|
68
|
+
t.references foreign_name, type: foreign_key_type, foreign_key: true
|
69
|
+
}
|
70
|
+
|
71
|
+
create_test_model(:referrer, create_table) do
|
72
|
+
belongs_to_enum foreign_name
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'can be created from enum value' do
|
77
|
+
model.values.each do |v|
|
78
|
+
t = other_model.new(foreign_name => v)
|
79
|
+
expect(t).to be_valid
|
80
|
+
.and have_attributes(foreign_name => v,
|
81
|
+
foreign_key => v.ordinal)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'can be created from constant name' do
|
86
|
+
model.values.each do |v|
|
87
|
+
t = other_model.new(foreign_name => v.enum_constant)
|
88
|
+
expect(t).to be_valid
|
89
|
+
.and have_attributes(foreign_name => v,
|
90
|
+
foreign_key => v.ordinal)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'can be created from ordinal' do
|
95
|
+
model.values.each do |v|
|
96
|
+
t = other_model.new(foreign_key => v.ordinal)
|
97
|
+
expect(t).to be_valid
|
98
|
+
.and have_attributes(foreign_name => v,
|
99
|
+
foreign_key => v.ordinal)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
it 'can be created with null foreign key' do
|
104
|
+
t = other_model.new
|
105
|
+
expect(t).to be_valid
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'can not be created with invalid foreign key' do
|
109
|
+
t = other_model.new(foreign_key => -1)
|
110
|
+
expect(t).not_to be_valid
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'can not be created with invalid foreign constant' do
|
114
|
+
expect {
|
115
|
+
other_model.new(foreign_name => :BadConstant)
|
116
|
+
}.to raise_error(NameError)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
context 'with an enum model' do
|
122
|
+
let(:model) do
|
123
|
+
create_test_model(:with_table, ->(t) { t.string :name; t.index [:name], unique: true }) do
|
124
|
+
acts_as_enum(CONSTANTS)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
it_behaves_like 'acts like a persisted enum'
|
129
|
+
|
130
|
+
it 'returns all values from the database' do
|
131
|
+
expect(model.all.map(&:to_sym)).to contain_exactly(*CONSTANTS)
|
132
|
+
end
|
133
|
+
|
134
|
+
it 'is immutable' do
|
135
|
+
expect { model.create(name: 'foo') }
|
136
|
+
.to raise_error(ActiveRecord::ReadOnlyRecord)
|
137
|
+
|
138
|
+
expect { model::ONE.name = 'foo' }
|
139
|
+
.to raise_error(RuntimeError, /can't modify frozen/i) # Frozen object
|
140
|
+
|
141
|
+
expect { model.first.update_attribute(:name, 'foo') }
|
142
|
+
.to raise_error(ActiveRecord::ReadOnlyRecord)
|
143
|
+
|
144
|
+
expect { model.first.destroy }
|
145
|
+
.to raise_error(ActiveRecord::ReadOnlyRecord)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
context 'with a validation on the model' do
|
150
|
+
let(:model) do
|
151
|
+
create_test_model(:with_table, ->(t) { t.string :name; t.index [:name], unique: true }) do
|
152
|
+
yield if block_given?
|
153
|
+
validates :name, exclusion: { in: [CONSTANTS.first.to_s] }
|
154
|
+
acts_as_enum(CONSTANTS)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'does not admit invalid values' do
|
159
|
+
expect { model }
|
160
|
+
.to raise_error(ActiveRecord::RecordInvalid)
|
161
|
+
end
|
162
|
+
|
163
|
+
context 'with existing invalid values' do
|
164
|
+
let(:model) do
|
165
|
+
super() do
|
166
|
+
create(name: CONSTANTS.first.to_s)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
it 'does not admit invalid values' do
|
171
|
+
expect { model }
|
172
|
+
.to raise_error(ActiveRecord::RecordInvalid)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def self.with_dummy_rake(&block)
|
178
|
+
context 'with rake defined' do
|
179
|
+
around(:each) do |example|
|
180
|
+
rake = OpenStruct.new(application: OpenStruct.new(top_level_tasks: [:fake]))
|
181
|
+
Object.const_set(:Rake, rake)
|
182
|
+
example.run
|
183
|
+
Object.send(:remove_const, :Rake)
|
184
|
+
end
|
185
|
+
|
186
|
+
instance_exec(&block)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
shared_examples 'falls back to a dummy model' do |name_attr: 'name', sql_enum: false|
|
191
|
+
context 'without rake defined' do
|
192
|
+
it 'raises the error directly' do
|
193
|
+
expect { model }.to raise_error(PersistentEnum::EnumTableInvalid)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
with_dummy_rake do
|
198
|
+
it 'warns that it is falling back' do
|
199
|
+
expect(model).to be_present
|
200
|
+
expect(ActiveRecord::Base.logger)
|
201
|
+
.to have_received(:warn)
|
202
|
+
.with(a_string_matching(/Database table initialization error.*dummy records/))
|
203
|
+
end
|
204
|
+
|
205
|
+
it 'makes a dummy model' do
|
206
|
+
dummy_model = PersistentEnum.dummy_class(model, name_attr)
|
207
|
+
expect(dummy_model).to be_present
|
208
|
+
expect(model.values).to all(be_kind_of(dummy_model))
|
209
|
+
end
|
210
|
+
|
211
|
+
it 'initializes dummy values correctly' do
|
212
|
+
model.values.each do |val|
|
213
|
+
i = val.ordinal
|
214
|
+
enum_type = sql_enum ? String : Integer
|
215
|
+
expect(i).to be_a(enum_type)
|
216
|
+
expect(val.id).to eq(i)
|
217
|
+
expect(val['id']).to eq(i)
|
218
|
+
expect(val[:id]).to eq(i)
|
219
|
+
|
220
|
+
c = val.enum_constant
|
221
|
+
expect(c).to be_a(String)
|
222
|
+
expect(val.name).to eq(c)
|
223
|
+
expect(val['name']).to eq(c)
|
224
|
+
expect(val[:name]).to eq(c)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
context 'with a table-less enum' do
|
231
|
+
let(:model) do
|
232
|
+
create_test_model(:without_table, nil, create_table: false) do
|
233
|
+
acts_as_enum(CONSTANTS)
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
it_behaves_like 'falls back to a dummy model'
|
238
|
+
|
239
|
+
with_dummy_rake do
|
240
|
+
it_behaves_like 'acts like an enum'
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
context 'with existing data' do
|
245
|
+
let(:initial_ordinal) { 9998 }
|
246
|
+
let(:initial_constant) { CONSTANTS.first }
|
247
|
+
|
248
|
+
let(:existing_ordinal) { 9999 }
|
249
|
+
let(:existing_constant) { :Hello }
|
250
|
+
|
251
|
+
let!(:model) do
|
252
|
+
model = create_test_model(:with_existing, ->(t) { t.string :name; t.index [:name], unique: true })
|
253
|
+
@initial_value = model.create(id: initial_ordinal, name: initial_constant.to_s)
|
254
|
+
@existing_value = model.create(id: existing_ordinal, name: existing_constant.to_s)
|
255
|
+
model.acts_as_enum(CONSTANTS)
|
256
|
+
model
|
257
|
+
end
|
258
|
+
|
259
|
+
it_behaves_like 'acts like a persisted enum'
|
260
|
+
|
261
|
+
let(:expected_all) { (CONSTANTS + [existing_constant]) }
|
262
|
+
let(:expected_required) { CONSTANTS }
|
263
|
+
|
264
|
+
it 'caches required values' do
|
265
|
+
expect(model.values.map(&:to_sym)).to contain_exactly(*expected_required)
|
266
|
+
end
|
267
|
+
|
268
|
+
it 'caches all values' do
|
269
|
+
expect(model.all_values.map(&:to_sym)).to contain_exactly(*expected_all)
|
270
|
+
end
|
271
|
+
|
272
|
+
it 'loads all values' do
|
273
|
+
expect(model.all.map(&:to_sym)).to contain_exactly(*expected_all)
|
274
|
+
end
|
275
|
+
|
276
|
+
let(:required_ordinals) { expected_required.map { |name| model.value_of!(name).ordinal } }
|
277
|
+
let(:all_ordinals) { expected_all.map { |name| model.value_of!(name).ordinal } }
|
278
|
+
|
279
|
+
it 'caches required ordinals' do
|
280
|
+
expect(model.ordinals).to contain_exactly(*required_ordinals)
|
281
|
+
end
|
282
|
+
|
283
|
+
it 'caches all ordinals' do
|
284
|
+
expect(model.all_ordinals).to contain_exactly(*all_ordinals)
|
285
|
+
end
|
286
|
+
|
287
|
+
it 'loads all ordinals' do
|
288
|
+
expect(model.pluck(:id)).to contain_exactly(*all_ordinals)
|
289
|
+
end
|
290
|
+
|
291
|
+
it 'respects initial value' do
|
292
|
+
expect(model[initial_ordinal]).to eq(@initial_value)
|
293
|
+
expect(model.value_of(initial_constant)).to eq(@initial_value)
|
294
|
+
expect(model.where(name: initial_constant).first).to eq(@initial_value)
|
295
|
+
end
|
296
|
+
|
297
|
+
it 'respects existing value' do
|
298
|
+
expect(model[existing_ordinal]).to eq(@existing_value)
|
299
|
+
expect(model.value_of(existing_constant)).to eq(@existing_value)
|
300
|
+
expect(model.where(name: existing_constant).first).to eq(@existing_value)
|
301
|
+
end
|
302
|
+
|
303
|
+
it 'marks existing model as non-active' do
|
304
|
+
expect(model[existing_ordinal]).to_not be_active
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
context 'with cached constants' do
|
309
|
+
let(:model) do
|
310
|
+
create_test_model(:with_constants, ->(t) { t.string :name; t.index [:name], unique: true }) do
|
311
|
+
PersistentEnum.cache_constants(self, CONSTANTS)
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
it 'caches all the constants' do
|
316
|
+
CONSTANTS.each do |c|
|
317
|
+
cached = model.const_get(c.upcase)
|
318
|
+
expect(cached).to be_present
|
319
|
+
expect(cached.name).to eq(c.to_s)
|
320
|
+
|
321
|
+
loaded = model.find_by(name: c.to_s)
|
322
|
+
expect(loaded).to be_present.and eq(cached)
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
context 'with complex constant names' do
|
328
|
+
let(:test_constants) do
|
329
|
+
{
|
330
|
+
'CamelCase' => 'CAMEL_CASE',
|
331
|
+
:Symbolic => 'SYMBOLIC',
|
332
|
+
'with.punctuation' => 'WITH_PUNCTUATION',
|
333
|
+
'multiple_.underscores' => 'MULTIPLE_UNDERSCORES',
|
334
|
+
}
|
335
|
+
end
|
336
|
+
|
337
|
+
let(:model) do
|
338
|
+
test_constants = test_constants()
|
339
|
+
create_test_model(:with_complex_names, ->(t) { t.string :name; t.index [:name], unique: true }) do
|
340
|
+
PersistentEnum.cache_constants(self, test_constants.keys)
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
it 'caches the constant name as we expect' do
|
345
|
+
test_constants.each do |expected_name, expected_constant|
|
346
|
+
val = model.const_get(expected_constant)
|
347
|
+
expect(val).to be_present
|
348
|
+
expect(val.name).to eq(expected_name.to_s)
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|
352
|
+
|
353
|
+
context 'with extra fields' do
|
354
|
+
let(:fields) {
|
355
|
+
[:count]
|
356
|
+
}
|
357
|
+
let(:defaults) do
|
358
|
+
{ count: 4 }
|
359
|
+
end
|
360
|
+
|
361
|
+
let(:members) do
|
362
|
+
{
|
363
|
+
:One => { count: 1 },
|
364
|
+
:Two => { count: 2 },
|
365
|
+
:Three => { count: 3 },
|
366
|
+
:Four => {},
|
367
|
+
}
|
368
|
+
end
|
369
|
+
|
370
|
+
shared_examples 'acts like an enum with extra fields' do
|
371
|
+
it 'has all expected members with expected values' do
|
372
|
+
members.each do |name, member_fields|
|
373
|
+
ev = model.value_of(name)
|
374
|
+
|
375
|
+
# Ensure it exists and is correctly saved
|
376
|
+
expect(ev).to be_present
|
377
|
+
expect(model.values).to include(ev)
|
378
|
+
expect(model.all_values).to include(ev)
|
379
|
+
expect(ev).to eq(model[ev.ordinal])
|
380
|
+
|
381
|
+
# Ensure it's correctly saved
|
382
|
+
if model.table_exists?
|
383
|
+
expect(model.where(name: name).first).to eq(ev)
|
384
|
+
end
|
385
|
+
|
386
|
+
# and that fields have been correctly set
|
387
|
+
fields.each do |field|
|
388
|
+
expected =
|
389
|
+
member_fields.fetch(field) { defaults[field] if model.table_exists? }
|
390
|
+
expect(ev[field]).to eq(expected)
|
391
|
+
end
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
shared_examples 'acts like a persisted enum with extra fields' do
|
397
|
+
include_examples 'acts like an enum with extra fields'
|
398
|
+
end
|
399
|
+
|
400
|
+
context 'providing a hash' do
|
401
|
+
let(:model) do
|
402
|
+
members = members()
|
403
|
+
create_test_model(:with_extra_field, ->(t) {
|
404
|
+
t.string :name
|
405
|
+
t.integer :count, default: 4
|
406
|
+
t.index [:name], unique: true
|
407
|
+
}) do
|
408
|
+
# pre-existing matching, non-matching, and outdated data
|
409
|
+
create(name: 'One', count: 3)
|
410
|
+
create(name: 'Two', count: 2)
|
411
|
+
create(name: 'Zero', count: 0)
|
412
|
+
|
413
|
+
acts_as_enum(members)
|
414
|
+
end
|
415
|
+
end
|
416
|
+
|
417
|
+
it_behaves_like 'acts like a persisted enum'
|
418
|
+
it_behaves_like 'acts like a persisted enum with extra fields'
|
419
|
+
|
420
|
+
it 'keeps outdated data' do
|
421
|
+
z = model.value_of('Zero')
|
422
|
+
expect(z).to be_present
|
423
|
+
expect(model[z.ordinal]).to eq(z)
|
424
|
+
expect(z.count).to eq(0)
|
425
|
+
expect(model.all_values).to include(z)
|
426
|
+
expect(model.values).not_to include(z)
|
427
|
+
end
|
428
|
+
|
429
|
+
context 'when reinitializing' do
|
430
|
+
let!(:model) { super() }
|
431
|
+
|
432
|
+
it 'uses the same constant' do
|
433
|
+
old_constant = model.const_get(:ONE)
|
434
|
+
model.reinitialize_acts_as_enum
|
435
|
+
new_constant = model.const_get(:ONE)
|
436
|
+
expect(new_constant).to equal(old_constant)
|
437
|
+
expect(new_constant).to equal(model.value_of('One'))
|
438
|
+
end
|
439
|
+
|
440
|
+
it 'handles a change in an attribute' do
|
441
|
+
old_constant = model.const_get(:ONE)
|
442
|
+
|
443
|
+
state = model._acts_as_enum_state
|
444
|
+
new_spec =
|
445
|
+
PersistentEnum::EnumSpec.new(
|
446
|
+
nil,
|
447
|
+
state.required_members.merge({ 'One' => { count: 1111 } }),
|
448
|
+
state.name_attr,
|
449
|
+
state.sql_enum_type)
|
450
|
+
model.initialize_acts_as_enum(new_spec)
|
451
|
+
|
452
|
+
new_constant = model.const_get(:ONE)
|
453
|
+
expect(new_constant.count).to eq(1111)
|
454
|
+
expect(new_constant).to equal(old_constant)
|
455
|
+
expect(new_constant).to equal(model.value_of('One'))
|
456
|
+
end
|
457
|
+
|
458
|
+
it 'handles a change in enum constant by replacing' do
|
459
|
+
model.where(name: 'One').update_all(name: 'MoreThanOne')
|
460
|
+
old_constant = model.const_get(:ONE).reload
|
461
|
+
# Note this has completely broken the enum indexing. Not relevant to test.
|
462
|
+
expect(old_constant.name).to eq('MoreThanOne')
|
463
|
+
|
464
|
+
model.reinitialize_acts_as_enum
|
465
|
+
new_constant = model.const_get(:ONE)
|
466
|
+
expect(new_constant.name).to eq('One')
|
467
|
+
expect(new_constant).not_to equal(old_constant)
|
468
|
+
expect(new_constant).to equal(model.value_of('One'))
|
469
|
+
end
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
context 'using builder interface' do
|
474
|
+
let(:model) do
|
475
|
+
create_test_model(:with_extra_field_using_builder, ->(t) { t.string :name; t.integer :count; t.index [:name], unique: true }) do
|
476
|
+
acts_as_enum(nil) do
|
477
|
+
One(count: 1)
|
478
|
+
Two(count: 2)
|
479
|
+
constant!(:Three, count: 3)
|
480
|
+
Four(count: 4)
|
481
|
+
end
|
482
|
+
end
|
483
|
+
end
|
484
|
+
|
485
|
+
it_behaves_like 'acts like a persisted enum'
|
486
|
+
it_behaves_like 'acts like a persisted enum with extra fields'
|
487
|
+
end
|
488
|
+
|
489
|
+
context 'with closed over builder' do
|
490
|
+
let(:model) do
|
491
|
+
v = 0
|
492
|
+
create_test_model(:with_dynamic_builder, ->(t) { t.string :name; t.integer :count; t.index [:name], unique: true }) do
|
493
|
+
acts_as_enum(nil) do
|
494
|
+
Member(count: (v += 1))
|
495
|
+
end
|
496
|
+
end
|
497
|
+
end
|
498
|
+
|
499
|
+
it 'can reinitialize with a changed value' do
|
500
|
+
expect(model.value_of('Member').count).to eq(1)
|
501
|
+
model.reinitialize_acts_as_enum
|
502
|
+
expect(model.value_of('Member').count).to eq(2)
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
context 'without table' do
|
507
|
+
let(:model) do
|
508
|
+
members = members()
|
509
|
+
create_test_model(:with_extra_field_without_table, nil, create_table: false) do
|
510
|
+
acts_as_enum(members)
|
511
|
+
end
|
512
|
+
end
|
513
|
+
|
514
|
+
it_behaves_like 'falls back to a dummy model'
|
515
|
+
|
516
|
+
with_dummy_rake do
|
517
|
+
it_behaves_like 'acts like an enum'
|
518
|
+
it_behaves_like 'acts like an enum with extra fields'
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
context 'with missing required attributes' do
|
523
|
+
let(:model) do
|
524
|
+
create_test_model(:test_invalid_args_a, ->(t) { t.string :name; t.integer :count, null: false; t.index [:name], unique: true }) do
|
525
|
+
acts_as_enum([:Bad])
|
526
|
+
end
|
527
|
+
end
|
528
|
+
|
529
|
+
it_behaves_like 'falls back to a dummy model'
|
530
|
+
|
531
|
+
with_dummy_rake do
|
532
|
+
it 'warns that the required attributes were missing' do
|
533
|
+
expect(model.logger)
|
534
|
+
.to have_received(:warn)
|
535
|
+
.with(a_string_matching(/required attributes.*not provided/))
|
536
|
+
end
|
537
|
+
end
|
538
|
+
end
|
539
|
+
|
540
|
+
context 'with attributes with defaults' do
|
541
|
+
def prepopulate(table); end
|
542
|
+
|
543
|
+
let(:model) do
|
544
|
+
pp_handle = method(:prepopulate)
|
545
|
+
create_test_model(:test_invalid_args_b, ->(t) {
|
546
|
+
t.string :name
|
547
|
+
t.integer :count, default: 1, null: false
|
548
|
+
t.integer :maybe
|
549
|
+
t.index [:name], unique: true
|
550
|
+
}) do
|
551
|
+
pp_handle.call(self)
|
552
|
+
acts_as_enum(nil) do
|
553
|
+
One()
|
554
|
+
Two(count: 2)
|
555
|
+
Three(maybe: 1)
|
556
|
+
end
|
557
|
+
end
|
558
|
+
end
|
559
|
+
|
560
|
+
it 'allows defaults to be omitted' do
|
561
|
+
o = model.value_of('One')
|
562
|
+
expect(o).to be_present
|
563
|
+
expect(o.count).to eq(1)
|
564
|
+
expect(o.maybe).to eq(nil)
|
565
|
+
|
566
|
+
t = model.value_of('Two')
|
567
|
+
expect(t).to be_present
|
568
|
+
expect(t.count).to eq(2)
|
569
|
+
expect(t.maybe).to eq(nil)
|
570
|
+
|
571
|
+
r = model.value_of('Three')
|
572
|
+
expect(r).to be_present
|
573
|
+
expect(r.count).to eq(1)
|
574
|
+
expect(r.maybe).to eq(1)
|
575
|
+
end
|
576
|
+
|
577
|
+
context 'with non-default existing values' do
|
578
|
+
def prepopulate(table)
|
579
|
+
table.create!(name: 'One', count: 10, maybe: 10)
|
580
|
+
end
|
581
|
+
|
582
|
+
it 'asserts the defaults when omitted' do
|
583
|
+
o = model.value_of('One')
|
584
|
+
expect(o).to be_present
|
585
|
+
expect(o.count).to eq(1)
|
586
|
+
expect(o.maybe).to eq(nil)
|
587
|
+
end
|
588
|
+
end
|
589
|
+
end
|
590
|
+
|
591
|
+
it 'warns if nonexistent attributes are provided' do
|
592
|
+
create_test_model(:test_invalid_args_c, ->(t) { t.string :name; t.index [:name], unique: true }) do
|
593
|
+
acts_as_enum({ :One => { incorrect: 1 } })
|
594
|
+
end
|
595
|
+
|
596
|
+
expect(ActiveRecord::Base.logger)
|
597
|
+
.to have_received(:warn)
|
598
|
+
.with(a_string_matching(/missing from table/))
|
599
|
+
end
|
600
|
+
|
601
|
+
context 'with array typed extra fields', :postgresql do
|
602
|
+
let(:fields) {
|
603
|
+
[:counts]
|
604
|
+
}
|
605
|
+
|
606
|
+
let(:defaults) do
|
607
|
+
{ counts: [4, 40] }
|
608
|
+
end
|
609
|
+
|
610
|
+
let(:members) do
|
611
|
+
{
|
612
|
+
:One => { counts: [1, 10] },
|
613
|
+
:Two => { counts: [2, 20] },
|
614
|
+
:Three => { counts: [3, 30] },
|
615
|
+
:Four => {},
|
616
|
+
}
|
617
|
+
end
|
618
|
+
|
619
|
+
let(:model) do
|
620
|
+
members = members()
|
621
|
+
create_test_model(:with_extra_array_field, ->(t) {
|
622
|
+
t.string :name;
|
623
|
+
t.column :counts, 'integer[]', default: '{4, 40}'
|
624
|
+
t.index [:name], unique: true
|
625
|
+
}) do
|
626
|
+
# pre-existing matching, non-matching, and outdated data
|
627
|
+
create(name: 'One', counts: [3])
|
628
|
+
create(name: 'Two', counts: [2])
|
629
|
+
create(name: 'Zero', counts: [0])
|
630
|
+
|
631
|
+
acts_as_enum(members)
|
632
|
+
end
|
633
|
+
end
|
634
|
+
|
635
|
+
it_behaves_like 'acts like a persisted enum'
|
636
|
+
it_behaves_like 'acts like a persisted enum with extra fields'
|
637
|
+
end
|
638
|
+
end
|
639
|
+
|
640
|
+
context 'using a postgresql enum valued id', :postgresql do
|
641
|
+
let(:name) { 'with_enum_id' }
|
642
|
+
let(:enum_type) { "#{name}_type" }
|
643
|
+
|
644
|
+
context 'with table' do
|
645
|
+
before(:each) do
|
646
|
+
ActiveRecord::Base.connection.execute("CREATE TYPE #{enum_type} AS ENUM ()")
|
647
|
+
ActiveRecord::Base.connection.create_table(name.pluralize, id: false) do |t|
|
648
|
+
t.column :id, enum_type, primary_key: true, null: false
|
649
|
+
t.string :name
|
650
|
+
t.index [:name], unique: true
|
651
|
+
end
|
652
|
+
end
|
653
|
+
|
654
|
+
after(:each) do
|
655
|
+
ActiveRecord::Base.connection.execute("DROP TYPE #{enum_type} CASCADE")
|
656
|
+
end
|
657
|
+
|
658
|
+
let(:model) do
|
659
|
+
enum_type = enum_type()
|
660
|
+
create_test_model(:with_enum_id, nil, create_table: false) do
|
661
|
+
acts_as_enum(CONSTANTS, sql_enum_type: enum_type)
|
662
|
+
end
|
663
|
+
end
|
664
|
+
|
665
|
+
it_behaves_like 'acts like a persisted enum'
|
666
|
+
end
|
667
|
+
|
668
|
+
context 'without table' do
|
669
|
+
let(:model) do
|
670
|
+
enum_type = enum_type()
|
671
|
+
create_test_model(:no_table_enum_id, nil, create_table: false) do
|
672
|
+
acts_as_enum(CONSTANTS, sql_enum_type: enum_type)
|
673
|
+
end
|
674
|
+
end
|
675
|
+
|
676
|
+
it_behaves_like 'falls back to a dummy model', sql_enum: true
|
677
|
+
with_dummy_rake do
|
678
|
+
it_behaves_like 'acts like an enum'
|
679
|
+
end
|
680
|
+
end
|
681
|
+
end
|
682
|
+
|
683
|
+
context 'with the name of the enum value column changed' do
|
684
|
+
let(:model) do
|
685
|
+
create_test_model(:test_new_name, ->(t) { t.string :namey; t.index [:namey], unique: true }) do
|
686
|
+
acts_as_enum(CONSTANTS, name_attr: :namey)
|
687
|
+
end
|
688
|
+
end
|
689
|
+
it_behaves_like 'acts like a persisted enum'
|
690
|
+
end
|
691
|
+
|
692
|
+
it 'refuses to create a table in a transaction' do
|
693
|
+
expect {
|
694
|
+
ActiveRecord::Base.transaction do
|
695
|
+
create_test_model(:test_create_in_transaction, ->(t) { t.string :name; t.index [:name], unique: true }) do
|
696
|
+
acts_as_enum([:A, :B])
|
697
|
+
end
|
698
|
+
end
|
699
|
+
}.to raise_error(RuntimeError, /unsafe class initialization during/)
|
700
|
+
end
|
701
|
+
|
702
|
+
context 'without an index on the enum constant' do
|
703
|
+
let(:model) do
|
704
|
+
create_test_model(:test_create_without_index, ->(t) { t.string :name }) do
|
705
|
+
acts_as_enum([:A, :B])
|
706
|
+
end
|
707
|
+
end
|
708
|
+
|
709
|
+
it_behaves_like 'falls back to a dummy model'
|
710
|
+
|
711
|
+
with_dummy_rake do
|
712
|
+
it 'warns that the unique index was missing' do
|
713
|
+
expect(model.logger)
|
714
|
+
.to have_received(:warn)
|
715
|
+
.with(a_string_matching(/missing unique index/))
|
716
|
+
end
|
717
|
+
end
|
718
|
+
end
|
719
|
+
|
720
|
+
context 'without a unique index on the enum constant' do
|
721
|
+
let(:model) do
|
722
|
+
create_test_model(:test_create_without_index, ->(t) { t.string :name; t.index [:name] }) do
|
723
|
+
acts_as_enum([:A, :B])
|
724
|
+
end
|
725
|
+
end
|
726
|
+
|
727
|
+
it_behaves_like 'falls back to a dummy model'
|
728
|
+
|
729
|
+
with_dummy_rake do
|
730
|
+
it 'warns that the unique index was missing' do
|
731
|
+
expect(model.logger)
|
732
|
+
.to have_received(:warn)
|
733
|
+
.with(a_string_matching(/missing unique index/))
|
734
|
+
end
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
context 'with an empty constants array' do
|
739
|
+
let(:initial_ordinal) { 9998 }
|
740
|
+
let(:initial_constant) { CONSTANTS.first }
|
741
|
+
|
742
|
+
let(:model) do
|
743
|
+
model = create_test_model(:with_empty_constants, ->(t) { t.string :name; t.index [:name], unique: true })
|
744
|
+
@prior_value = model.create!(id: initial_ordinal, name: initial_constant.to_s)
|
745
|
+
model.acts_as_enum([])
|
746
|
+
model
|
747
|
+
end
|
748
|
+
|
749
|
+
it 'looks up the existing value' do
|
750
|
+
expect(model.value_of(initial_constant)).to eq(@prior_value)
|
751
|
+
expect(model[initial_ordinal]).to eq(@prior_value)
|
752
|
+
end
|
753
|
+
|
754
|
+
it 'caches the existing value' do
|
755
|
+
expect(model.all_values).to eq([@prior_value])
|
756
|
+
end
|
757
|
+
end
|
758
|
+
end
|