super_settings 0.0.0.rc1
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/CHANGELOG.md +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +313 -0
- data/VERSION +1 -0
- data/app/helpers/super_settings/settings_helper.rb +32 -0
- data/app/views/layouts/super_settings/settings.html.erb +20 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20210414004553_create_super_settings.rb +34 -0
- data/lib/super_settings/application/api.js +88 -0
- data/lib/super_settings/application/helper.rb +119 -0
- data/lib/super_settings/application/images/edit.svg +1 -0
- data/lib/super_settings/application/images/info.svg +1 -0
- data/lib/super_settings/application/images/plus.svg +1 -0
- data/lib/super_settings/application/images/slash.svg +1 -0
- data/lib/super_settings/application/images/trash.svg +1 -0
- data/lib/super_settings/application/index.html.erb +169 -0
- data/lib/super_settings/application/layout.html.erb +22 -0
- data/lib/super_settings/application/layout_styles.css +193 -0
- data/lib/super_settings/application/scripts.js +718 -0
- data/lib/super_settings/application/styles.css +122 -0
- data/lib/super_settings/application.rb +38 -0
- data/lib/super_settings/attributes.rb +24 -0
- data/lib/super_settings/coerce.rb +66 -0
- data/lib/super_settings/configuration.rb +144 -0
- data/lib/super_settings/controller_actions.rb +81 -0
- data/lib/super_settings/encryption.rb +76 -0
- data/lib/super_settings/engine.rb +70 -0
- data/lib/super_settings/history_item.rb +26 -0
- data/lib/super_settings/local_cache.rb +306 -0
- data/lib/super_settings/rack_middleware.rb +210 -0
- data/lib/super_settings/rest_api.rb +195 -0
- data/lib/super_settings/setting.rb +599 -0
- data/lib/super_settings/storage/active_record_storage.rb +123 -0
- data/lib/super_settings/storage/http_storage.rb +279 -0
- data/lib/super_settings/storage/redis_storage.rb +293 -0
- data/lib/super_settings/storage/test_storage.rb +158 -0
- data/lib/super_settings/storage.rb +254 -0
- data/lib/super_settings/version.rb +5 -0
- data/lib/super_settings.rb +213 -0
- data/lib/tasks/super_settings.rake +9 -0
- data/super_settings.gemspec +35 -0
- metadata +113 -0
@@ -0,0 +1,599 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SuperSettings
|
4
|
+
# This is the model for interacting with settings. This class provides methods for finding, validating, and
|
5
|
+
# updating settings.
|
6
|
+
#
|
7
|
+
# This class does not deal with actually persisting settings to and fetching them from a data store.
|
8
|
+
# You need to specify the storage engine you want to use with the `storage` class method. This gem
|
9
|
+
# ships with storage engines for ActiveRecord, Redis, and HTTP (microservice). See the SuperSettings::Storage
|
10
|
+
# class for more details.
|
11
|
+
class Setting
|
12
|
+
LAST_UPDATED_CACHE_KEY = "SuperSettings.last_updated_at"
|
13
|
+
|
14
|
+
STRING = "string"
|
15
|
+
INTEGER = "integer"
|
16
|
+
FLOAT = "float"
|
17
|
+
BOOLEAN = "boolean"
|
18
|
+
DATETIME = "datetime"
|
19
|
+
ARRAY = "array"
|
20
|
+
SECRET = "secret"
|
21
|
+
|
22
|
+
VALUE_TYPES = [STRING, INTEGER, FLOAT, BOOLEAN, DATETIME, ARRAY, SECRET].freeze
|
23
|
+
|
24
|
+
ARRAY_DELIMITER = /[\n\r]+/.freeze
|
25
|
+
|
26
|
+
# Exception raised if you try to save with invalid data.
|
27
|
+
class InvalidRecordError < StandardError
|
28
|
+
end
|
29
|
+
|
30
|
+
include Attributes
|
31
|
+
|
32
|
+
# The changed_by attribute is used to temporarily store an identifier for the user
|
33
|
+
# who made a change to a setting to be stored in the history table. This value is optional
|
34
|
+
# and is cleared after the record is saved.
|
35
|
+
attr_accessor :changed_by
|
36
|
+
|
37
|
+
class << self
|
38
|
+
# Set a cache to use for caching values. This feature is optional. The cache must respond
|
39
|
+
# to `delete(key)` and `fetch(key, &block)`. If you are running in a Rails environment,
|
40
|
+
# you can use `Rails.cache` or any ActiveSupport::Cache::Store object.
|
41
|
+
attr_accessor :cache
|
42
|
+
|
43
|
+
# Set the storage class to use for persisting data.
|
44
|
+
attr_writer :storage
|
45
|
+
|
46
|
+
# @return [Class] The storage class to use for persisting data.
|
47
|
+
# @api private
|
48
|
+
def storage
|
49
|
+
if defined?(@storage)
|
50
|
+
@storage
|
51
|
+
elsif defined?(::SuperSettings::Storage::ActiveRecordStorage)
|
52
|
+
::SuperSettings::Storage::ActiveRecordStorage
|
53
|
+
else
|
54
|
+
raise ArgumentError.new("No storage class defined for #{name}")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Create a new setting with the specified attributes.
|
59
|
+
# @param attributes [Hash] hash of attribute names and values
|
60
|
+
# @return [Setting]
|
61
|
+
def create!(attributes)
|
62
|
+
setting = new(attributes)
|
63
|
+
storage.with_connection do
|
64
|
+
setting.save!
|
65
|
+
end
|
66
|
+
setting
|
67
|
+
end
|
68
|
+
|
69
|
+
# Get all the settings. This will even return settings that have been marked as deleted.
|
70
|
+
# If you just want current settings, then call #active instead.
|
71
|
+
# @return [Array<Setting>]
|
72
|
+
def all
|
73
|
+
storage.with_connection do
|
74
|
+
storage.all.collect { |record| new(record) }
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Get all the current settings.
|
79
|
+
# @return [Array<Setting>]
|
80
|
+
def active
|
81
|
+
storage.with_connection do
|
82
|
+
storage.active.collect { |record| new(record) }
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Get all settings that have been updated since the specified time stamp.
|
87
|
+
# @param time [Time]
|
88
|
+
# @return [Array<Setting>]
|
89
|
+
def updated_since(time)
|
90
|
+
storage.with_connection do
|
91
|
+
storage.updated_since(time).collect { |record| new(record) }
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Get a setting by its unique key.
|
96
|
+
# @return Setting
|
97
|
+
def find_by_key(key)
|
98
|
+
record = storage.with_connection { storage.find_by_key(key) }
|
99
|
+
if record
|
100
|
+
new(record)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Return the maximum updated at value from all the rows. This is used in the caching
|
105
|
+
# scheme to determine if data needs to be reloaded from the database.
|
106
|
+
# @return [Time]
|
107
|
+
def last_updated_at
|
108
|
+
fetch_from_cache(LAST_UPDATED_CACHE_KEY) do
|
109
|
+
storage.with_connection { storage.last_updated_at }
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Bulk update settings in a single database transaction. No changes will be saved
|
114
|
+
# if there are any invalid records.
|
115
|
+
#
|
116
|
+
# Example:
|
117
|
+
#
|
118
|
+
# ```
|
119
|
+
# SuperSettings.bulk_update([
|
120
|
+
# {
|
121
|
+
# key: "setting-key",
|
122
|
+
# value: "foobar",
|
123
|
+
# value_type: "string",
|
124
|
+
# description: "A sample setting"
|
125
|
+
# },
|
126
|
+
# {
|
127
|
+
# key: "setting-to-delete",
|
128
|
+
# deleted: true
|
129
|
+
# }
|
130
|
+
# ])
|
131
|
+
# ```
|
132
|
+
#
|
133
|
+
# @param params [Array] Array of hashes with setting attributes. Each hash must include
|
134
|
+
# a "key" element to identify the setting. To update a key, it must also include at least
|
135
|
+
# one of "value", "value_type", or "description". If one of these attributes is present in
|
136
|
+
# the hash, it will be updated. If a setting with the given key does not exist, it will be created.
|
137
|
+
# A setting may also be deleted by providing the attribute "deleted: true".
|
138
|
+
# @return [Array] Boolean indicating if update succeeded, Array of settings affected by the update;
|
139
|
+
# if the settings were not updated, the `errors` on the settings that failed validation will be filled.
|
140
|
+
def bulk_update(params, changed_by = nil)
|
141
|
+
all_valid, settings = update_settings(params, changed_by)
|
142
|
+
if all_valid
|
143
|
+
storage.with_connection do
|
144
|
+
storage.transaction do
|
145
|
+
settings.each do |setting|
|
146
|
+
setting.save!
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
clear_last_updated_cache
|
151
|
+
end
|
152
|
+
[all_valid, settings]
|
153
|
+
end
|
154
|
+
|
155
|
+
# Determine the value type from a value.
|
156
|
+
def value_type(value)
|
157
|
+
case value
|
158
|
+
when Integer
|
159
|
+
INTEGER
|
160
|
+
when Numeric
|
161
|
+
FLOAT
|
162
|
+
when TrueClass, FalseClass
|
163
|
+
BOOLEAN
|
164
|
+
when Time, Date
|
165
|
+
DATETIME
|
166
|
+
when Array
|
167
|
+
ARRAY
|
168
|
+
else
|
169
|
+
STRING
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Clear the last updated timestamp from the cache.
|
174
|
+
# @api private
|
175
|
+
def clear_last_updated_cache
|
176
|
+
cache&.delete(Setting::LAST_UPDATED_CACHE_KEY)
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
# Updates settings in memory from an array of parameters.
|
182
|
+
# @param params [Array<Hash>] Each hash must contain a `key` element and may contain elements
|
183
|
+
# for `value`, `value_type`, `description`, and `deleted`.
|
184
|
+
# @param changed_by [String] Value to be stored in the history for each setting
|
185
|
+
# @return [Array] The first value is a boolean indicating if all the settings are valid,
|
186
|
+
# the second is an array of settings with their attributes updated in memory and ready to be saved.
|
187
|
+
def update_settings(params, changed_by)
|
188
|
+
changed = {}
|
189
|
+
all_valid = true
|
190
|
+
|
191
|
+
params.each do |setting_params|
|
192
|
+
setting_params = stringify_keys(setting_params)
|
193
|
+
next if Coerce.blank?(setting_params["key"])
|
194
|
+
next if ["value_type", "value", "description", "deleted"].all? { |name| Coerce.blank?(setting_params[name]) }
|
195
|
+
|
196
|
+
key = setting_params["key"]
|
197
|
+
setting = changed[key] || Setting.find_by_key(key)
|
198
|
+
unless setting
|
199
|
+
next if Coerce.present?(setting_params["delete"])
|
200
|
+
setting = Setting.new(key: setting_params["key"])
|
201
|
+
end
|
202
|
+
|
203
|
+
if Coerce.boolean(setting_params["deleted"])
|
204
|
+
setting.deleted = true
|
205
|
+
setting.changed_by = changed_by
|
206
|
+
else
|
207
|
+
setting.value_type = setting_params["value_type"] if setting_params.include?("value_type")
|
208
|
+
setting.value = setting_params["value"] if setting_params.include?("value")
|
209
|
+
setting.description = setting_params["description"] if setting_params.include?("description")
|
210
|
+
setting.deleted = false if setting.deleted?
|
211
|
+
setting.changed_by = changed_by
|
212
|
+
all_valid &= setting.valid?
|
213
|
+
end
|
214
|
+
changed[setting.key] = setting
|
215
|
+
end
|
216
|
+
|
217
|
+
[all_valid, changed.values]
|
218
|
+
end
|
219
|
+
|
220
|
+
def fetch_from_cache(key, &block)
|
221
|
+
if cache
|
222
|
+
cache.fetch(key, expires_in: 60, &block)
|
223
|
+
else
|
224
|
+
block.call
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def stringify_keys(hash)
|
229
|
+
transformed = {}
|
230
|
+
hash.each do |key, value|
|
231
|
+
transformed[key.to_s] = value
|
232
|
+
end
|
233
|
+
transformed
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# @param attributes [Hash]
|
238
|
+
def initialize(attributes = {})
|
239
|
+
@changes = {}
|
240
|
+
@errors = {}
|
241
|
+
if attributes.is_a?(Storage)
|
242
|
+
@record = attributes
|
243
|
+
else
|
244
|
+
@record = self.class.storage.new
|
245
|
+
self.attributes = attributes
|
246
|
+
self.value_type ||= STRING
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
# @return [String] the unique key for the setting.
|
251
|
+
def key
|
252
|
+
@record.key
|
253
|
+
end
|
254
|
+
|
255
|
+
# Set the value of the setting.
|
256
|
+
# @param val [String]
|
257
|
+
def key=(val)
|
258
|
+
val = val&.to_s
|
259
|
+
will_change!(:key, val) unless key == val
|
260
|
+
@record.key = val
|
261
|
+
end
|
262
|
+
|
263
|
+
# @return [Object] the value of a setting coerced to the appropriate class depending on its value type.
|
264
|
+
def value
|
265
|
+
if deleted?
|
266
|
+
nil
|
267
|
+
else
|
268
|
+
coerce(raw_value)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# Set the value of the setting.
|
273
|
+
# @param val [Object]
|
274
|
+
def value=(val)
|
275
|
+
val = serialize(val) unless val.is_a?(Array)
|
276
|
+
val = val.join("\n") if val.is_a?(Array)
|
277
|
+
self.raw_value = val
|
278
|
+
end
|
279
|
+
|
280
|
+
def value_type
|
281
|
+
@record.value_type
|
282
|
+
end
|
283
|
+
|
284
|
+
# Set the value type of the setting.
|
285
|
+
# @param val [String] one of string, integer, float, boolean, datetime, array, or secret.
|
286
|
+
def value_type=(val)
|
287
|
+
val = val&.to_s
|
288
|
+
will_change!(:value_type, val) unless value_type == val
|
289
|
+
@record.value_type = val
|
290
|
+
end
|
291
|
+
|
292
|
+
def description
|
293
|
+
@record.description
|
294
|
+
end
|
295
|
+
|
296
|
+
# @param val [String]
|
297
|
+
def description=(val)
|
298
|
+
val = val&.to_s
|
299
|
+
val = nil if val&.empty?
|
300
|
+
will_change!(:description, val) unless description == val
|
301
|
+
@record.description = val
|
302
|
+
end
|
303
|
+
|
304
|
+
def deleted?
|
305
|
+
@record.deleted?
|
306
|
+
end
|
307
|
+
|
308
|
+
alias_method :deleted, :deleted?
|
309
|
+
|
310
|
+
# Set the deleted flag on the setting. Deleted settings are not visible but are not actually
|
311
|
+
# removed from the data store.
|
312
|
+
# @param val [Boolean]
|
313
|
+
def deleted=(val)
|
314
|
+
val = Coerce.boolean(val)
|
315
|
+
will_change!(:deleted, val) unless deleted? == val
|
316
|
+
@record.deleted = val
|
317
|
+
end
|
318
|
+
|
319
|
+
def created_at
|
320
|
+
@record.created_at
|
321
|
+
end
|
322
|
+
|
323
|
+
# @param val [Time, DateTime]
|
324
|
+
def created_at=(val)
|
325
|
+
val = Coerce.time(val)
|
326
|
+
will_change!(:created_at, val) unless created_at == val
|
327
|
+
@record.created_at = val
|
328
|
+
end
|
329
|
+
|
330
|
+
def updated_at
|
331
|
+
@record.updated_at
|
332
|
+
end
|
333
|
+
|
334
|
+
# @param val [Time, DateTime]
|
335
|
+
def updated_at=(val)
|
336
|
+
val = Coerce.time(val)
|
337
|
+
will_change!(:updated_at, val) unless updated_at == val
|
338
|
+
@record.updated_at = val
|
339
|
+
end
|
340
|
+
|
341
|
+
# @return [true] if the setting has a string value type.
|
342
|
+
def string?
|
343
|
+
value_type == STRING
|
344
|
+
end
|
345
|
+
|
346
|
+
# @return [true] if the setting has an integer value type.
|
347
|
+
def integer?
|
348
|
+
value_type == INTEGER
|
349
|
+
end
|
350
|
+
|
351
|
+
# @return [true] if the setting has a float value type.
|
352
|
+
def float?
|
353
|
+
value_type == FLOAT
|
354
|
+
end
|
355
|
+
|
356
|
+
# @return [true] if the setting has a boolean value type.
|
357
|
+
def boolean?
|
358
|
+
value_type == BOOLEAN
|
359
|
+
end
|
360
|
+
|
361
|
+
# @return [true] if the setting has a datetime value type.
|
362
|
+
def datetime?
|
363
|
+
value_type == DATETIME
|
364
|
+
end
|
365
|
+
|
366
|
+
# @return [true] if the setting has an array value type.
|
367
|
+
def array?
|
368
|
+
value_type == ARRAY
|
369
|
+
end
|
370
|
+
|
371
|
+
# @return [true] if the setting has a secret value type.
|
372
|
+
def secret?
|
373
|
+
value_type == SECRET
|
374
|
+
end
|
375
|
+
|
376
|
+
# @return [true] if the setting is a secret setting and the value is encrypted in the database.
|
377
|
+
def encrypted?
|
378
|
+
secret? && Encryption.encrypted?(raw_value)
|
379
|
+
end
|
380
|
+
|
381
|
+
# Save the setting to the data storage engine.
|
382
|
+
# @return [void]
|
383
|
+
def save!
|
384
|
+
set_raw_value
|
385
|
+
|
386
|
+
unless valid?
|
387
|
+
raise InvalidRecordError.new(errors.values.join("; "))
|
388
|
+
end
|
389
|
+
|
390
|
+
timestamp = Time.now
|
391
|
+
self.created_at ||= timestamp
|
392
|
+
self.updated_at = timestamp unless updated_at && changed?(:updated_at)
|
393
|
+
|
394
|
+
self.class.storage.with_connection do
|
395
|
+
self.class.storage.transaction do
|
396
|
+
@record.save!
|
397
|
+
end
|
398
|
+
|
399
|
+
begin
|
400
|
+
self.class.clear_last_updated_cache
|
401
|
+
redact_history! if history_needs_redacting?
|
402
|
+
ensure
|
403
|
+
clear_changes
|
404
|
+
end
|
405
|
+
end
|
406
|
+
nil
|
407
|
+
end
|
408
|
+
|
409
|
+
# @return [Boolean] true if the record has been stored in the data storage engine.
|
410
|
+
def persisted?
|
411
|
+
@record.persisted?
|
412
|
+
end
|
413
|
+
|
414
|
+
# @return [Boolean] true if the record has valid data.
|
415
|
+
def valid?
|
416
|
+
validate!
|
417
|
+
@errors.empty?
|
418
|
+
end
|
419
|
+
|
420
|
+
# @return [Hash<String, Array<String>>] hash of errors generated from the last call to `valid?`
|
421
|
+
attr_reader :errors
|
422
|
+
|
423
|
+
# Mark the record as deleted. The record will not actually be deleted since it's still needed
|
424
|
+
# for caching purposes, but it will no longer be returned by queries.
|
425
|
+
def delete!
|
426
|
+
update!(deleted: true)
|
427
|
+
end
|
428
|
+
|
429
|
+
# Update the setting attributes and save it.
|
430
|
+
# @param attributes [Hash]
|
431
|
+
# @return [void]
|
432
|
+
def update!(attributes)
|
433
|
+
self.attributes = attributes
|
434
|
+
save!
|
435
|
+
end
|
436
|
+
|
437
|
+
# Return array of history items reflecting changes made to the setting over time. Items
|
438
|
+
# should be returned in reverse chronological order so that the most recent changes are first.
|
439
|
+
# @return [Array<SuperSettings::History>]
|
440
|
+
def history(limit: nil, offset: 0)
|
441
|
+
@record.history(limit: limit, offset: offset)
|
442
|
+
end
|
443
|
+
|
444
|
+
# Serialize to a hash that is used for rendering JSON responses.
|
445
|
+
# @return [Hash]
|
446
|
+
def as_json(options = nil)
|
447
|
+
attributes = {
|
448
|
+
key: key,
|
449
|
+
value: value,
|
450
|
+
value_type: value_type,
|
451
|
+
description: description,
|
452
|
+
created_at: created_at,
|
453
|
+
updated_at: updated_at
|
454
|
+
}
|
455
|
+
attributes[:encrypted] = encrypted? if secret?
|
456
|
+
attributes[:deleted] = true if deleted?
|
457
|
+
attributes
|
458
|
+
end
|
459
|
+
|
460
|
+
# Serialize to a JSON string.
|
461
|
+
# @return [String]
|
462
|
+
def to_json(options = nil)
|
463
|
+
as_json.to_json(options)
|
464
|
+
end
|
465
|
+
|
466
|
+
private
|
467
|
+
|
468
|
+
# Coerce a value for the appropriate value type.
|
469
|
+
def coerce(value)
|
470
|
+
return nil if value.respond_to?(:empty?) ? value.empty? : value.to_s.empty?
|
471
|
+
|
472
|
+
case value_type
|
473
|
+
when Setting::STRING
|
474
|
+
value.freeze
|
475
|
+
when Setting::INTEGER
|
476
|
+
Integer(value)
|
477
|
+
when Setting::FLOAT
|
478
|
+
Float(value)
|
479
|
+
when Setting::BOOLEAN
|
480
|
+
Coerce.boolean(value)
|
481
|
+
when Setting::DATETIME
|
482
|
+
Coerce.time(value).freeze
|
483
|
+
when Setting::ARRAY
|
484
|
+
if value.is_a?(String)
|
485
|
+
value.split(Setting::ARRAY_DELIMITER).map(&:freeze).freeze
|
486
|
+
else
|
487
|
+
Array(value).reject { |v| v.respond_to?(:empty?) ? v.empty? : v.to_s.empty? }.collect { |v| v.to_s.freeze }.freeze
|
488
|
+
end
|
489
|
+
when Setting::SECRET
|
490
|
+
begin
|
491
|
+
Encryption.decrypt(value).freeze
|
492
|
+
rescue Encryption::InvalidSecretError
|
493
|
+
nil
|
494
|
+
end
|
495
|
+
else
|
496
|
+
value.freeze
|
497
|
+
end
|
498
|
+
rescue ArgumentError
|
499
|
+
nil
|
500
|
+
end
|
501
|
+
|
502
|
+
# Format the value so it can be saved as a string in the database.
|
503
|
+
def serialize(value)
|
504
|
+
if value.nil? || value.to_s.empty?
|
505
|
+
nil
|
506
|
+
elsif value.is_a?(Time) || value.is_a?(DateTime)
|
507
|
+
value.utc.iso8601(6)
|
508
|
+
else
|
509
|
+
coerce(value.to_s)
|
510
|
+
end
|
511
|
+
end
|
512
|
+
|
513
|
+
# Set the raw string value that will be persisted to the data store.
|
514
|
+
def set_raw_value
|
515
|
+
if value_type == Setting::SECRET && !raw_value.to_s.empty? && (changed?(:raw_value) || !Encryption.encrypted?(raw_value))
|
516
|
+
self.raw_value = Encryption.encrypt(raw_value)
|
517
|
+
end
|
518
|
+
record_value_change
|
519
|
+
end
|
520
|
+
|
521
|
+
# Update the histories association whenever the value or key is changed.
|
522
|
+
def record_value_change
|
523
|
+
return unless changed?(:raw_value) || changed?(:deleted) || changed?(:key)
|
524
|
+
recorded_value = (deleted? || value_type == Setting::SECRET ? nil : raw_value)
|
525
|
+
@record.create_history(value: recorded_value, deleted: deleted?, changed_by: changed_by, created_at: Time.now)
|
526
|
+
end
|
527
|
+
|
528
|
+
def clear_changes
|
529
|
+
@changes = {}
|
530
|
+
self.changed_by = nil
|
531
|
+
end
|
532
|
+
|
533
|
+
def will_change!(attribute, value)
|
534
|
+
attribute = attribute.to_s
|
535
|
+
change = @changes[attribute]
|
536
|
+
unless change
|
537
|
+
change = [send(attribute)]
|
538
|
+
@changes[attribute] = change
|
539
|
+
end
|
540
|
+
change[1] = value # rubocop:disable Lint/UselessSetterCall
|
541
|
+
end
|
542
|
+
|
543
|
+
def changed?(attribute)
|
544
|
+
@changes.include?(attribute.to_s)
|
545
|
+
end
|
546
|
+
|
547
|
+
def history_needs_redacting?
|
548
|
+
value_type == Setting::SECRET && changed?(:value_type)
|
549
|
+
end
|
550
|
+
|
551
|
+
def redact_history!
|
552
|
+
@record.send(:redact_history!)
|
553
|
+
end
|
554
|
+
|
555
|
+
def raw_value=(val)
|
556
|
+
val = val&.to_s
|
557
|
+
val = nil if val&.empty?
|
558
|
+
will_change!(:raw_value, val) unless raw_value == val
|
559
|
+
@raw_value = val
|
560
|
+
@record.raw_value = val
|
561
|
+
end
|
562
|
+
|
563
|
+
def raw_value
|
564
|
+
@record.raw_value
|
565
|
+
end
|
566
|
+
|
567
|
+
def validate!
|
568
|
+
if key.to_s.empty?
|
569
|
+
add_error(:key, "cannot be empty")
|
570
|
+
elsif key.to_s.size > 190
|
571
|
+
add_error(:key, "must be less than 190 characters")
|
572
|
+
end
|
573
|
+
|
574
|
+
add_error(:value_type, "must be one of #{Setting::VALUE_TYPES.join(", ")}") unless Setting::VALUE_TYPES.include?(value_type)
|
575
|
+
|
576
|
+
add_error(:value, "must be less than 4096 characters") if raw_value.to_s.size > 4096
|
577
|
+
|
578
|
+
if !raw_value.nil? && coerce(raw_value).nil?
|
579
|
+
if value_type == Setting::INTEGER
|
580
|
+
add_error(:value, "must be an integer")
|
581
|
+
elsif value_type == Setting::FLOAT
|
582
|
+
add_error(:value, "must be a number")
|
583
|
+
elsif value_type == Setting::DATETIME
|
584
|
+
add_error(:value, "is not a valid datetime")
|
585
|
+
end
|
586
|
+
end
|
587
|
+
end
|
588
|
+
|
589
|
+
def add_error(attribute, message)
|
590
|
+
attribute = attribute.to_s
|
591
|
+
attribute_errors = @errors[attribute]
|
592
|
+
unless attribute_errors
|
593
|
+
attribute_errors = []
|
594
|
+
@errors[attribute] = attribute_errors
|
595
|
+
end
|
596
|
+
attribute_errors << "#{attribute.tr("_", " ")} #{message}"
|
597
|
+
end
|
598
|
+
end
|
599
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SuperSettings
|
4
|
+
module Storage
|
5
|
+
# ActiveRecord implementation of the SuperSettings::Storage model.
|
6
|
+
#
|
7
|
+
# To use this model, you must run the migration included with the gem. The migration
|
8
|
+
# can be installed with `rake app:super_settings:install:migrations` if the gem is mounted
|
9
|
+
# as an engine in a Rails application.
|
10
|
+
class ActiveRecordStorage
|
11
|
+
# Base class that the models extend from.
|
12
|
+
class ApplicationRecord < ActiveRecord::Base
|
13
|
+
self.abstract_class = true
|
14
|
+
end
|
15
|
+
|
16
|
+
class Model < ApplicationRecord
|
17
|
+
self.table_name = "super_settings"
|
18
|
+
|
19
|
+
has_many :history_items, class_name: "SuperSettings::Storage::ActiveRecordStorage::HistoryModel", foreign_key: :key, primary_key: :key
|
20
|
+
end
|
21
|
+
|
22
|
+
class HistoryModel < ApplicationRecord
|
23
|
+
self.table_name = "super_settings_histories"
|
24
|
+
|
25
|
+
# Since these models are created automatically on a callback, ensure that the data will
|
26
|
+
# fit into the database columns since we can't handle any validation errors.
|
27
|
+
before_validation do
|
28
|
+
self.changed_by = changed_by.to_s[0, 150] if changed_by.present?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
include Storage
|
33
|
+
|
34
|
+
class << self
|
35
|
+
def all
|
36
|
+
if Model.table_exists?
|
37
|
+
Model.all.collect { |model| new(model) }
|
38
|
+
else
|
39
|
+
[]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def active
|
44
|
+
if Model.table_exists?
|
45
|
+
Model.where(deleted: false).collect { |model| new(model) }
|
46
|
+
else
|
47
|
+
[]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def updated_since(time)
|
52
|
+
if Model.table_exists?
|
53
|
+
Model.where("updated_at > ?", time).collect { |model| new(model) }
|
54
|
+
else
|
55
|
+
[]
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def find_by_key(key)
|
60
|
+
model = Model.where(deleted: false).find_by(key: key) if Model.table_exists?
|
61
|
+
new(model) if model
|
62
|
+
end
|
63
|
+
|
64
|
+
def last_updated_at
|
65
|
+
if Model.table_exists?
|
66
|
+
Model.maximum(:updated_at)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def with_connection(&block)
|
71
|
+
Model.connection_pool.with_connection(&block)
|
72
|
+
end
|
73
|
+
|
74
|
+
def transaction(&block)
|
75
|
+
Model.transaction(&block)
|
76
|
+
end
|
77
|
+
|
78
|
+
protected
|
79
|
+
|
80
|
+
# Only load settings asynchronously if there is an extra database connection left in the
|
81
|
+
# connection pool or if the configuration has explicitly allowed it.
|
82
|
+
def default_load_asynchronous?
|
83
|
+
Model.connection_pool.size > Thread.list.size
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
delegate :key, :key=, :raw_value, :raw_value=, :value_type, :value_type=, :description, :description=,
|
88
|
+
:deleted?, :deleted=, :updated_at, :updated_at=, :created_at, :created_at=, :persisted?, :save!,
|
89
|
+
to: :@model
|
90
|
+
|
91
|
+
def initialize(attributes = {})
|
92
|
+
@model = if attributes.is_a?(Model)
|
93
|
+
attributes
|
94
|
+
else
|
95
|
+
Model.new(attributes)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def history(limit: nil, offset: 0)
|
100
|
+
finder = @model.history_items.order(id: :desc).offset(offset)
|
101
|
+
finder = finder.limit(limit) if limit
|
102
|
+
finder.collect do |record|
|
103
|
+
HistoryItem.new(key: key, value: record.value, changed_by: record.changed_by, created_at: record.created_at, deleted: record.deleted?)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def create_history(changed_by:, created_at:, value: nil, deleted: false)
|
108
|
+
history_attributes = {value: value, deleted: deleted, changed_by: changed_by, created_at: created_at}
|
109
|
+
if @model.persisted?
|
110
|
+
@model.history_items.create!(history_attributes)
|
111
|
+
else
|
112
|
+
@model.history_items.build(history_attributes)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
protected
|
117
|
+
|
118
|
+
def redact_history!
|
119
|
+
@model.history_items.update_all(value: nil)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|