super_settings 0.0.1.rc3 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9562c88fa68bb2d609f007ed31371870706ab0e12a772857caefc7d4f84fb89
4
- data.tar.gz: f434c13622fe523791b543d9c04f5023cbdcee3d265dcbd2b2d9da0947780c64
3
+ metadata.gz: f58b7aa08a4a6082168ff9fa1487f79ff84bf9374b9463d9ba3ebe4e188b6a47
4
+ data.tar.gz: b4bf73f87885acacc091e3f455a8a33a8b0baa770dd00db42a1fac6b468e5427
5
5
  SHA512:
6
- metadata.gz: b13fa738e17df5bd4693939e4c182b4b81b668e0b113a6067e63d436f118e9362438f2503b40c7f6de05f5675653f00fae571c96195d55b3c56b6f557243a29e
7
- data.tar.gz: 2bbc6ee2b3059a136e134f4204c94cc4ca0437380ca132de5275be7b3a8c7623c9ae4e2b42f8aa268ea814c74b3ecf12b9fc6a370252eaa65501599b8a3da5f5
6
+ metadata.gz: 973a1ac38488a2cdc4d4cc639f9057d8a1cfd24e216cd7b8cfe4b034af5085b3e0245999b918f3aac7a95416d47fcba46f099d8312afaa2406f63bd5a17f1d25
7
+ data.tar.gz: e1922327a1c8965dd72d8b11826f398b7dd0cd4f3a3f27be7cc3aa5a81f44da925a159ae2321a122267a9605dd7fbb5fa6af909d4daeeae3cf371087b4df7a7f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.0.1]
8
+
9
+ ### Added
10
+ - Optimize object shapes for the Ruby interpreter by declaring instance variables in constructors.
11
+
7
12
  ## [1.0.0]
8
13
 
9
14
  ### Added
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # SuperSettings
2
2
 
3
3
  [![Continuous Integration](https://github.com/bdurand/super_settings/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/super_settings/actions/workflows/continuous_integration.yml)
4
+ [![Regression Test](https://github.com/bdurand/super_settings/actions/workflows/regression_test.yml/badge.svg)](https://github.com/bdurand/super_settings/actions/workflows/regression_test.yml)
4
5
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
6
 
6
7
  This gem provides a framework for maintaining runtime application settings. Settings are persisted in a database but cached in memory for quick, efficient access. The settings are designed so they can be updated dynamically without requiring code deployment or restarting processes. The code scales very well and can easily handle very high throughput environments.
@@ -19,6 +20,10 @@ SuperSettings provides a simple interface for accessing settings backed by a thr
19
20
 
20
21
  There is also an out of the box Web UI and REST API for managing settings. You can specify data types for your settings (string, integer, float, boolean, datetime, or array) to ensure that values will be valid. You can also supply documentation for each setting so that it's clear what each one does and how it is used.
21
22
 
23
+ Changes to settings are stored whenever a setting is changed to give you an audit trail if you need it for compliance reasons. In addition, you can provide your own callbacks to execute whenever a setting is changed.
24
+
25
+ There is a companion gem [ultra_settings](https://github.com/bdurand/ultra_settings) that can be used to integrate SuperSettings into a combined configuration system alongside YAML files and environment variables.
26
+
22
27
  ## Usage
23
28
 
24
29
  - [Getting Value](#getting-values)
@@ -51,65 +56,6 @@ SuperSettings.datetime("key") # -> returns a `Time` object
51
56
  SuperSettings.array("key") # -> returns an array of strings
52
57
  ```
53
58
 
54
- #### Hashes
55
- There is also a method to get multiple settings at once structured as a hash.
56
-
57
- ```ruby
58
- SuperSettings.structured("parent") # -> returns an hash
59
- ```
60
-
61
- The key provided to the `SuperSettings.structured` method indicates the key prefix and constructs the hash from settings that have keys beginning with that prefix. Keys are also broken down by a delimiter so you can create nested hashes. The delimiter defaults to `"."`, but you can specify a different one with the `delimiter` keyword argument.
62
-
63
- You can also set a maximum depth to the returned hash with the `max_depth` keyword argument.
64
-
65
- So, if you have the following settings:
66
-
67
- ```
68
- vendors.company_1.path = "/co1"
69
- vendors.company_1.timeout = 5
70
- vendors.company_2.path = "/co2"
71
- page_size = 20
72
- ```
73
-
74
- You would get these results:
75
-
76
- ```ruby
77
- SuperSettings.structured("vendors")
78
- # {
79
- # "company_1" => {
80
- # "path" => "/co1",
81
- # "timeout" => 5
82
- # },
83
- # "company_2" => {
84
- # "path" => "/co2"
85
- # }
86
- # }
87
-
88
- SuperSettings.structured("vendors.company_1")
89
- # {"path" => "/co1", "timeout" => 5}
90
-
91
- SuperSettings.structured("vendors.company_2")
92
- # {"path" => "/co2"}
93
-
94
- # Get all the settings by omitting the key
95
- SuperSettings.structured
96
- # {
97
- # "vendors" => {
98
- # "company_1" => {"path" => "/co1", "timeout" => 5},
99
- # "company_2" => {"path" => "/co2"}
100
- # },
101
- # "page_size" => 20
102
- # }
103
-
104
- # Limit the nesting depth of the returned hash to one level
105
- SuperSettings.structured(max_depth: 1)
106
- # {
107
- # "vendors.company_1.path => "/co1",
108
- # "vendors.company_1.timeout" => 5,
109
- # "vendors.company_2.path" => "/co2",
110
- # "page_size" => 20
111
- # }
112
- ```
113
59
 
114
60
  #### Defaults
115
61
 
@@ -132,13 +78,38 @@ SuperSettings.enabled?("enabled_users.#{id}")
132
78
 
133
79
  # GOOD: use an array if there are a limited number of values
134
80
  SuperSettings.array("enabled_users", []).include?(id)
135
-
136
- # GOOD: use a hash if you need to scale to any number of values
137
- SuperSettings.structured("enabled_users", {})["id"]
138
81
  ```
139
82
 
140
83
  The cache will scale without issue to handle hundreds of settings. However, you should avoid creating thousands of settings. Because all settings are read into memory, having too many settings records can lead to performance or memory issues.
141
84
 
85
+ #### Request Context
86
+
87
+ You can ensure that settings won't change in a block of code by surrounding it with a `SuperSettings.context` block. Inside a `context` block, a setting will always return the same value. This can prevent race conditions where you code may branch based on a setting value.
88
+
89
+ ```ruby
90
+ # This code could be unsafe since the value of the "threshold" setting could
91
+ # change after the if statement is checked.
92
+ if SuperSettings.integer("threshold") > 0
93
+ do_something(SuperSettings.integer("threshold"))
94
+ end
95
+
96
+ # With a context block, the value for the "threshold setting will always
97
+ # return the same value
98
+ SuperSettings.context do
99
+ if SuperSettings.integer("threshold") > 0
100
+ do_something(SuperSettings.integer("threshold"))
101
+ end
102
+ end
103
+ ```
104
+
105
+ It's a good idea to add a `context` block around your main unit of work:
106
+
107
+ - Rack application: add `SuperSettings::Context::RackMiddleware` to your middleware stack
108
+ - Sidekiq: add `SuperSettings::Context::SidekiqMiddleware` to your server middleware
109
+ - ActiveJob: add an `around_perform` callback that calls `SuperSettings.context`
110
+
111
+ In a Rails application all of these will be done automatically.
112
+
142
113
  ### Data Model
143
114
 
144
115
  Each setting has a key, value, value type, and optional description. The key must be unique. The value type can be one of "string", "integer", "float", "boolean", "datetime", or "array". The array value type will always return an array of strings.
@@ -149,6 +120,16 @@ It is not possible to store an empty string in a setting; empty strings will be
149
120
 
150
121
  A history of all settings changes is updated every time the value is changed in the `histories` association. You can also record who made the changes.
151
122
 
123
+ #### Callbacks
124
+
125
+ You can define custom callbacks on the `SuperSettings::Setting` model that will be called whenever a setting is changed. For example, if you needed to log all changes to you settings in your application logs, you could do something like this:
126
+
127
+ ```ruby
128
+ SuperSettings::Setting.after_save do |setting|
129
+ Application.logger.info("Setting #{setting.key} changed: #{setting.changes.inspect})
130
+ end
131
+ ```
132
+
152
133
  #### Storage Engines
153
134
 
154
135
  This gem abstracts out the storage engine and can support multiple storage mechanisms. It has built in support for ActiveRecord, Redis, and HTTP storage.
@@ -220,7 +201,7 @@ The gem ships with a Rails engine that provides easy integration with a Rails ap
220
201
  The default storage engine for a Rails application will be the ActiveRecord storage. You need to install the database migrations first with:
221
202
 
222
203
  ```bash
223
- rails app:super_settings:install:migrations
204
+ rails super_settings:install:migrations
224
205
  ```
225
206
 
226
207
  You also need to mount the engine routes in your application's `config/routes.rb` file. The routes can be mounted under any prefix you'd like.
@@ -270,10 +251,15 @@ SuperSettings.configure do |config|
270
251
  end
271
252
  end
272
253
 
273
- # Define a method that returns the value that will be stored in the settings history in
254
+ # Define a block that returns the value that will be stored in the settings history in
274
255
  # the `changed_by` column.
275
256
  config.controller.define_changed_by do
276
- current_user.name
257
+ current_user.id
258
+ end
259
+
260
+ # Define a block that determines how to display the `changed_by`` value in the setting history.
261
+ config.model.define_changed_by_display do |changed_by_id|
262
+ User.find_by(id: changed_by_id)&.name
277
263
  end
278
264
 
279
265
  # You can define the storage engine for the model. This can be either done either with a Class
@@ -284,6 +270,11 @@ SuperSettings.configure do |config|
284
270
  # You can also specify a cache implementation to use to cache the last updated timestamp
285
271
  # for model changes. By default this will use `Rails.cache`.
286
272
  # config.model.cache = Rails.cache
273
+
274
+ # You can define after_save callbacks for the model.
275
+ # config.model.after_save do |setting|
276
+ # Rail.logger.info("Setting #{setting.key} changed to #{setting.value.inspect}")
277
+ # end
287
278
  end
288
279
  ```
289
280
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.1.rc3
1
+ 1.0.1
@@ -19,7 +19,7 @@ module SuperSettings
19
19
  def super_settings_application_header
20
20
  config = Configuration.instance.controller
21
21
  content = "#{super_settings_application_name} Settings"
22
- if config.application_logo.present?
22
+ if Coerce.present?(config.application_logo)
23
23
  content = image_tag(config.application_logo, alt: "").concat(content)
24
24
  end
25
25
  if config.application_link
@@ -35,13 +35,19 @@
35
35
 
36
36
  params = options.params
37
37
  let queryParams = null;
38
- const fetchOptions = {credentials: "same-origin"};
38
+ const headers = new Headers();
39
+ const fetchOptions = {credentials: "same-origin", headers: headers};
39
40
  const accessToken = window.sessionStorage.getItem("super_settings_access_token");
40
- const headers = {"Accept": "application/json"};
41
+
42
+ headers.set("Accept", "application/json");
41
43
  if (accessToken) {
42
- headers["Authorization"] = "Bearer " + accessToken;
44
+ headers.set("Authorization", "Bearer " + accessToken);
43
45
  }
44
- Object.assign(headers, SuperSettingsAPI.headers);
46
+ Object.entries(SuperSettingsAPI.headers).forEach(function(entry) {
47
+ const [key, value] = entry;
48
+ headers.set(key, value);
49
+ });
50
+
45
51
  if (method === "POST") {
46
52
  queryParams = Object.assign({}, SuperSettingsAPI.queryParams);
47
53
  csrfParam = document.querySelector("meta[name=csrf-param]");
@@ -52,11 +58,10 @@
52
58
  }
53
59
  fetchOptions["method"] = "POST";
54
60
  fetchOptions["body"] = JSON.stringify(params);
55
- headers["Content-Type"] = "application/json";
61
+ headers.set("Content-Type", "application/json");
56
62
  } else {
57
63
  queryParams = Object.assign({}, SuperSettingsAPI.queryParams, params);
58
64
  }
59
- fetchOptions["headers"] = new Headers(headers);
60
65
  const url = apiURL(path, queryParams);
61
66
 
62
67
  fetch(url, fetchOptions)
@@ -22,8 +22,14 @@ module SuperSettings
22
22
  # since it will be compatible with class reloading in a development environment.
23
23
  attr_writer :superclass
24
24
 
25
+ def initialize
26
+ @superclass = nil
27
+ @web_ui_enabled = true
28
+ @changed_by_block = nil
29
+ end
30
+
25
31
  def superclass
26
- if defined?(@superclass) && @superclass.is_a?(String)
32
+ if @superclass.is_a?(String)
27
33
  @superclass.constantize
28
34
  else
29
35
  @superclass
@@ -54,9 +60,6 @@ module SuperSettings
54
60
  attr_writer :web_ui_enabled
55
61
 
56
62
  def web_ui_enabled?
57
- unless defined?(@web_ui_enabled)
58
- @web_ui_enabled = true
59
- end
60
63
  !!@web_ui_enabled
61
64
  end
62
65
 
@@ -87,7 +90,7 @@ module SuperSettings
87
90
  #
88
91
  # @api private
89
92
  def changed_by(controller)
90
- if defined?(@changed_by_block) && @changed_by_block
93
+ if @changed_by_block
91
94
  controller.instance_eval(&@changed_by_block)
92
95
  end
93
96
  end
@@ -101,15 +104,19 @@ module SuperSettings
101
104
 
102
105
  attr_writer :storage
103
106
 
107
+ attr_reader :after_save_blocks, :changed_by_display
108
+
109
+ def initialize
110
+ @storage = :active_record
111
+ @after_save_blocks = []
112
+ @changed_by_display = nil
113
+ end
114
+
104
115
  # Specify the storage engine to use for persisting settings. The value can either be specified
105
116
  # as a full class name or an underscored class name for a storage classed defined in the
106
117
  # SuperSettings::Storage namespace. The default storage engine is +SuperSettings::Storage::ActiveRecord+.
107
118
  def storage
108
- if defined?(@storage) && @storage
109
- @storage
110
- else
111
- :active_record
112
- end
119
+ @storage || :active_record
113
120
  end
114
121
 
115
122
  # @return [Class]
@@ -126,6 +133,27 @@ module SuperSettings
126
133
  end
127
134
  end
128
135
  end
136
+
137
+ # Add an after_save callback to the setting model. The block will be called with the
138
+ # setting object after it is saved.
139
+ #
140
+ # @yieldparam setting [SuperSettings::Setting]
141
+ def after_save(&block)
142
+ after_save_blocks << block
143
+ end
144
+
145
+ # Define how the changed_by attibute on the setting history will be displayed. The block
146
+ # will be called with the changed_by attribute and should return a string to display.
147
+ # The block will not be called if the changed_by attribute is nil.
148
+ #
149
+ # @example
150
+ # define_changed_by_display { |changed_by| User.find_by(id: changed_by)&.name }
151
+ #
152
+ # @yield Block of code to call on the controller at request time
153
+ # @yieldparam changed_by [String] The value of the changed_by attribute
154
+ def define_changed_by_display(&block)
155
+ @changed_by_display = block
156
+ end
129
157
  end
130
158
 
131
159
  # Return the model specific configuration object.
@@ -141,6 +169,7 @@ module SuperSettings
141
169
  def initialize
142
170
  @model = Model.new
143
171
  @controller = Controller.new
172
+ @deferred_configs = []
144
173
  end
145
174
 
146
175
  # Defer the execution of a block that will be yielded to with the config object. This
@@ -149,15 +178,16 @@ module SuperSettings
149
178
  #
150
179
  # @api private
151
180
  def defer(&block)
152
- @block = block
181
+ @deferred_configs << block
153
182
  end
154
183
 
155
184
  # Call the block deferred during initialization.
156
185
  #
157
186
  # @api private
158
187
  def call
159
- @block&.call(self)
160
- @block = nil
188
+ while (block = @deferred_configs.shift)
189
+ block&.call(self)
190
+ end
161
191
  end
162
192
  end
163
193
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Context
5
+ # Rack middleware you can use to add a context to your requests so that
6
+ # settings are not changed during request execution.
7
+ #
8
+ # This middleware is automatically added to Rails applications.
9
+ class RackMiddleware
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ SuperSettings.context do
16
+ @app.call(env)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Context
5
+ # Sidekiq middleware you can use to add a context to your jobs so that
6
+ # settings are not changed during job execution.
7
+ #
8
+ # @example
9
+ # require "super_settings/context/sidekiq_middleware"
10
+ #
11
+ # Sidekiq.configure_server do |config|
12
+ # config.server_middleware do |chain|
13
+ # chain.add SuperSettings::Context::SidekiqMiddleware
14
+ # end
15
+ # end
16
+ #
17
+ # You can disable the context by setting the `super_settings_context` key
18
+ # to `false` in the job payload.
19
+ #
20
+ # @example
21
+ # class MyWorker
22
+ # include Sidekiq::Worker
23
+ # sidekiq_options super_settings_context: false
24
+ # end
25
+ class SidekiqMiddleware
26
+ if defined?(Sidekiq::ServerMiddleware)
27
+ include Sidekiq::ServerMiddleware
28
+ end
29
+
30
+ def call(job_instance, job_payload, queue)
31
+ if job_payload["super_settings_context"] == false
32
+ yield
33
+ else
34
+ SuperSettings.context do
35
+ yield
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Context
5
+ end
6
+ end
@@ -7,6 +7,26 @@ module SuperSettings
7
7
  class Engine < Rails::Engine
8
8
  isolate_namespace ::SuperSettings
9
9
 
10
+ initializer("SuperSettings") do
11
+ Rails.configuration.middleware.unshift(SuperSettings::Context::RackMiddleware)
12
+
13
+ if defined?(ActiveJob::Base.around_perform)
14
+ ActiveJob::Base.around_perform do |job, block|
15
+ SuperSettings.context(&block)
16
+ end
17
+ end
18
+
19
+ if defined?(Sidekiq.server?) && Sidekiq.server?
20
+ require_relative "context/sidekiq_middleware"
21
+
22
+ Sidekiq.configure_server do |sidekiq_config|
23
+ sidekiq_config.server_middleware do |chain|
24
+ chain.prepend(SuperSettings::Context::SidekiqMiddleware)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
10
30
  config.after_initialize do
11
31
  # Call the deferred initialization block.
12
32
  configuration = Configuration.instance
@@ -46,6 +66,10 @@ module SuperSettings
46
66
  Setting.cache = (configuration.model.cache || Rails.cache)
47
67
  Setting.storage = configuration.model.storage_class
48
68
 
69
+ configuration.model.after_save_blocks.each do |block|
70
+ Setting.after_save(&block)
71
+ end
72
+
49
73
  if !SuperSettings.loaded?
50
74
  begin
51
75
  SuperSettings.load_settings
@@ -9,26 +9,30 @@ module SuperSettings
9
9
  attr_accessor :key, :value, :changed_by, :created_at
10
10
  attr_writer :deleted
11
11
 
12
+ def initialize(*)
13
+ @deleted = false
14
+ super
15
+ end
16
+
12
17
  def deleted?
13
- # Stupid strict mode...
14
- !!(defined?(@deleted) && @deleted)
18
+ !!@deleted
15
19
  end
16
20
 
17
- # The method could be overriden to change how the changed_by attribute is displayed.
18
- # For instance, you could store a user id in the changed_by column and add an association
19
- # on this model.
21
+ # The display value for the changed_by attribute. This method can be overridden
22
+ # in the configuration by calling `model.define_changed_by_display` with the block to use
23
+ # to get the display value for the changed_by attribute. The default value is
24
+ # the changed_by attribute itself.
20
25
  #
21
- # @example
22
- # class SuperSettings::HistoryItem
23
- # def changed_by_display
24
- # user = User.find_by(id: changed_by) if changed_by
25
- # user ? user.name : changed_by
26
- # end
27
- # end
28
- #
29
- # @return [String]
26
+ # @return [String, nil]
30
27
  def changed_by_display
31
- changed_by
28
+ return changed_by if changed_by.nil?
29
+
30
+ display_proc = Configuration.instance.model.changed_by_display
31
+ if display_proc && !changed_by.nil?
32
+ display_proc.call(changed_by) || changed_by
33
+ else
34
+ changed_by
35
+ end
32
36
  end
33
37
  end
34
38
  end
@@ -54,7 +54,6 @@ module SuperSettings
54
54
  @lock.synchronize do
55
55
  # For case where one thread could be iterating over the cache while it's updated causing an error
56
56
  @cache = @cache.merge(key => value).freeze
57
- @hashes = {}
58
57
  end
59
58
  end
60
59
  end
@@ -64,53 +63,6 @@ module SuperSettings
64
63
  value
65
64
  end
66
65
 
67
- # Return the setting as structured data. The keys will be split by the specified delimiter
68
- # to create a nested hash.
69
- #
70
- # @example
71
- # Setting with key "a.b.c" and value 1 becomes
72
- #
73
- # {
74
- # "a" => {
75
- # "b" => {
76
- # "c" => 1
77
- # }
78
- # }
79
- # }
80
- #
81
- # See SuperSettings.structured for more details.
82
- def structured(key = nil, delimiter: ".", max_depth: nil)
83
- key = key.to_s
84
- cache_key = [key, delimiter, max_depth]
85
- cached_value = @hashes[cache_key]
86
- return cached_value if cached_value
87
-
88
- flattened = to_h
89
- root_key = ""
90
- if Coerce.present?(key)
91
- root_key = "#{key}#{delimiter}"
92
- reduced_hash = {}
93
- flattened.each do |k, v|
94
- if k.start_with?(root_key)
95
- reduced_hash[k[root_key.length, k.length]] = v
96
- end
97
- end
98
- flattened = reduced_hash
99
- end
100
-
101
- structured_hash = {}
102
- flattened.each do |key, value|
103
- set_nested_hash_value(structured_hash, key, value, 0, delimiter: delimiter, max_depth: max_depth)
104
- end
105
-
106
- deep_freeze_hash(structured_hash)
107
- @lock.synchronize do
108
- @hashes[cache_key] = structured_hash
109
- end
110
-
111
- structured_hash
112
- end
113
-
114
66
  # Check if the cache includes a key. Note that this will return true if you have tried
115
67
  # to fetch a non-existent key since the cache will store that as undefined. This method
116
68
  # is provided for testing purposes.
@@ -217,7 +169,6 @@ module SuperSettings
217
169
  def reset
218
170
  @lock.synchronize do
219
171
  @cache = {}.freeze
220
- @hashes = {}
221
172
  @last_refreshed = nil
222
173
  @next_check_at = Time.now + @refresh_interval
223
174
  @refreshing = false
@@ -240,7 +191,6 @@ module SuperSettings
240
191
  return if Coerce.blank?(setting.key)
241
192
  @lock.synchronize do
242
193
  @cache = @cache.merge(setting.key => setting.value)
243
- @hashes = {}
244
194
  end
245
195
  end
246
196
 
@@ -277,7 +227,7 @@ module SuperSettings
277
227
  end
278
228
  load_settings(Setting.storage.load_asynchronous?)
279
229
  elsif Time.now >= @next_check_at
280
- refresh
230
+ refresh(Setting.storage.load_asynchronous?)
281
231
  end
282
232
  end
283
233
 
@@ -287,7 +237,6 @@ module SuperSettings
287
237
  @last_refreshed = refreshed_at_time
288
238
  @refreshing = false
289
239
  @cache = block.call.freeze
290
- @hashes = {}
291
240
  end
292
241
  end
293
242
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rack"
4
-
5
3
  module SuperSettings
6
4
  # Rack middleware for serving the REST API. See SuperSettings::RestAPI for more details on usage.
7
5
  #
@@ -39,6 +37,13 @@ module SuperSettings
39
37
  # end
40
38
  # end
41
39
  def initialize(app = nil, path_prefix = "/", &block)
40
+ # Requiring rack here so that the gem does not have a hard dependency on it.
41
+ begin
42
+ require "rack"
43
+ rescue LoadError
44
+ raise LoadError, "SuperSettings::RackApplication requires the rack gem"
45
+ end
46
+
42
47
  @app = app
43
48
  @path_prefix = path_prefix.to_s.chomp("/")
44
49
  instance_eval(&block) if block
@@ -22,6 +22,9 @@ module SuperSettings
22
22
 
23
23
  ARRAY_DELIMITER = /[\n\r]+/.freeze
24
24
 
25
+ NOT_SET = Object.new.freeze
26
+ private_constant :NOT_SET
27
+
25
28
  # Exception raised if you try to save with invalid data.
26
29
  class InvalidRecordError < StandardError
27
30
  end
@@ -33,6 +36,9 @@ module SuperSettings
33
36
  # and is cleared after the record is saved.
34
37
  attr_accessor :changed_by
35
38
 
39
+ @storage = NOT_SET
40
+ @after_save_blocks = []
41
+
36
42
  class << self
37
43
  # Set a cache to use for caching values. This feature is optional. The cache must respond
38
44
  # to +delete(key)+ and +fetch(key, &block)+. If you are running in a Rails environment,
@@ -42,18 +48,31 @@ module SuperSettings
42
48
  # Set the storage class to use for persisting data.
43
49
  attr_writer :storage
44
50
 
51
+ attr_reader :after_save_blocks
52
+
45
53
  # @return [Class] The storage class to use for persisting data.
46
54
  # @api private
47
55
  def storage
48
- if defined?(@storage)
49
- @storage
50
- elsif defined?(::SuperSettings::Storage::ActiveRecordStorage)
51
- ::SuperSettings::Storage::ActiveRecordStorage
56
+ if @storage == NOT_SET
57
+ if defined?(::SuperSettings::Storage::ActiveRecordStorage)
58
+ ::SuperSettings::Storage::ActiveRecordStorage
59
+ else
60
+ raise ArgumentError.new("No storage class defined for #{name}")
61
+ end
52
62
  else
53
- raise ArgumentError.new("No storage class defined for #{name}")
63
+ @storage
54
64
  end
55
65
  end
56
66
 
67
+ # Add a block of code that will be called when a setting is saved. The block will be
68
+ # called with a Setting object. The object will have been saved, but the `changes`
69
+ # hash will still be set indicating what was changed. You can define multiple after_save blocks.
70
+ #
71
+ # @yieldparam setting [SuperSetting::Setting]
72
+ def after_save(&block)
73
+ after_save_blocks << block
74
+ end
75
+
57
76
  # Create a new setting with the specified attributes.
58
77
  #
59
78
  # @param attributes [Hash] hash of attribute names and values
@@ -433,6 +452,7 @@ module SuperSettings
433
452
 
434
453
  begin
435
454
  self.class.clear_last_updated_cache
455
+ call_after_save_callbacks
436
456
  ensure
437
457
  clear_changes
438
458
  end
@@ -508,6 +528,15 @@ module SuperSettings
508
528
  as_json.to_json(options)
509
529
  end
510
530
 
531
+ # Get hash of attribute changes. The hash keys are the names of attributes that
532
+ # have changed and the values are an array with [old value, new value]. The keys
533
+ # will be one of key, raw_value, value_type, description, deleted, created_at, or updated_at.
534
+ #
535
+ # @return [Hash<String, Array>]
536
+ def changes
537
+ @changes.dup
538
+ end
539
+
511
540
  private
512
541
 
513
542
  # Coerce a value for the appropriate value type.
@@ -618,5 +647,11 @@ module SuperSettings
618
647
  end
619
648
  attribute_errors << "#{attribute.tr("_", " ")} #{message}"
620
649
  end
650
+
651
+ def call_after_save_callbacks
652
+ self.class.after_save_blocks.each do |block|
653
+ block.call(self)
654
+ end
655
+ end
621
656
  end
622
657
  end
@@ -14,6 +14,9 @@ module SuperSettings
14
14
  DEFAULT_HEADERS = {"Accept" => "application/json"}.freeze
15
15
  DEFAULT_TIMEOUT = 5.0
16
16
 
17
+ @headers = {}
18
+ @query_params = {}
19
+
17
20
  attr_reader :key, :raw_value, :description, :value_type, :updated_at, :created_at
18
21
 
19
22
  class Error < StandardError
@@ -36,12 +39,17 @@ module SuperSettings
36
39
 
37
40
  attr_accessor :key, :value, :changed_by, :deleted
38
41
 
42
+ def initialize(*)
43
+ @deleted = false
44
+ super
45
+ end
46
+
39
47
  def created_at=(val)
40
48
  @created_at = SuperSettings::Coerce.time(val)
41
49
  end
42
50
 
43
51
  def deleted?
44
- !!(defined?(@deleted) && @deleted)
52
+ !!@deleted
45
53
  end
46
54
  end
47
55
 
@@ -75,13 +83,9 @@ module SuperSettings
75
83
 
76
84
  attr_accessor :timeout
77
85
 
78
- def headers
79
- @headers ||= {}
80
- end
86
+ attr_reader :headers
81
87
 
82
- def query_params
83
- @query_params ||= {}
84
- end
88
+ attr_reader :query_params
85
89
 
86
90
  protected
87
91
 
@@ -178,6 +182,12 @@ module SuperSettings
178
182
  end
179
183
  end
180
184
 
185
+ def initialize(*)
186
+ @persisted = false
187
+ @deleted = false
188
+ super
189
+ end
190
+
181
191
  def save!
182
192
  payload = {key: key}
183
193
  if deleted?
@@ -248,11 +258,11 @@ module SuperSettings
248
258
  end
249
259
 
250
260
  def deleted?
251
- !!(defined?(@deleted) && @deleted)
261
+ !!@deleted
252
262
  end
253
263
 
254
264
  def persisted?
255
- !!(defined?(@persisted) && @persisted)
265
+ !!@persisted
256
266
  end
257
267
 
258
268
  private
@@ -65,6 +65,11 @@ module SuperSettings
65
65
  end
66
66
  end
67
67
 
68
+ def initialize(*)
69
+ @deleted = false
70
+ super
71
+ end
72
+
68
73
  def created_at=(val)
69
74
  @created_at = SuperSettings::Coerce.time(val)
70
75
  end
@@ -77,7 +82,7 @@ module SuperSettings
77
82
  end
78
83
 
79
84
  def deleted?
80
- !!(defined?(@deleted) && @deleted)
85
+ !!@deleted
81
86
  end
82
87
 
83
88
  private
@@ -184,6 +189,12 @@ module SuperSettings
184
189
  end
185
190
  end
186
191
 
192
+ def initialize(*)
193
+ @deleted = false
194
+ @persisted = false
195
+ super
196
+ end
197
+
187
198
  def history(limit: nil, offset: 0)
188
199
  HistoryStorage.find_all_by_key(key: key, limit: limit, offset: offset).collect do |record|
189
200
  HistoryItem.new(key: key, value: record.value, changed_by: record.changed_by, created_at: record.created_at, deleted: record.deleted?)
@@ -242,11 +253,11 @@ module SuperSettings
242
253
  end
243
254
 
244
255
  def deleted?
245
- !!(defined?(@deleted) && @deleted)
256
+ !!@deleted
246
257
  end
247
258
 
248
259
  def persisted?
249
- !!(defined?(@persisted) && @persisted)
260
+ !!@persisted
250
261
  end
251
262
 
252
263
  private
@@ -13,13 +13,13 @@ module SuperSettings
13
13
  attr_reader :key, :raw_value, :description, :value_type, :updated_at, :created_at
14
14
  attr_accessor :changed_by
15
15
 
16
+ @settings = {}
17
+ @history = {}
18
+
16
19
  class << self
17
- def settings
18
- @settings ||= {}
19
- end
20
+ attr_reader :settings
20
21
 
21
22
  def history(key)
22
- @history ||= {}
23
23
  items = @history[key]
24
24
  unless items
25
25
  items = []
@@ -68,6 +68,18 @@ module SuperSettings
68
68
  end
69
69
  end
70
70
 
71
+ def initialize(*)
72
+ @original_key = nil
73
+ @raw_value = nil
74
+ @created_at = nil
75
+ @updated_at = nil
76
+ @description = nil
77
+ @value_type = nil
78
+ @deleted = false
79
+ @persisted = false
80
+ super
81
+ end
82
+
71
83
  def history(limit: nil, offset: 0)
72
84
  items = self.class.history(key)
73
85
  items[offset, limit || items.length].collect do |attributes|
@@ -121,11 +133,11 @@ module SuperSettings
121
133
  end
122
134
 
123
135
  def deleted?
124
- !!(defined?(@deleted) && @deleted)
136
+ !!@deleted
125
137
  end
126
138
 
127
139
  def persisted?
128
- !!(defined?(@persisted) && @persisted)
140
+ !!@persisted
129
141
  end
130
142
 
131
143
  private
@@ -10,6 +10,8 @@ module SuperSettings
10
10
  def self.included(base)
11
11
  base.extend(ClassMethods)
12
12
  base.include(Attributes) unless base.instance_methods.include?(:attributes=)
13
+
14
+ base.instance_variable_set(:@load_asynchronous, nil)
13
15
  end
14
16
 
15
17
  module ClassMethods
@@ -76,7 +78,7 @@ module SuperSettings
76
78
  #
77
79
  # @return [Boolean]
78
80
  def load_asynchronous?
79
- !!((defined?(@load_asynchronous) && !@load_asynchronous.nil?) ? @load_asynchronous : default_load_asynchronous?)
81
+ !!(@load_asynchronous.nil? ? default_load_asynchronous? : @load_asynchronous)
80
82
  end
81
83
 
82
84
  # Set to true to force loading setting asynchronously in a background thread.
@@ -3,6 +3,8 @@
3
3
  require_relative "super_settings/application"
4
4
  require_relative "super_settings/coerce"
5
5
  require_relative "super_settings/configuration"
6
+ require_relative "super_settings/context"
7
+ require_relative "super_settings/context/rack_middleware"
6
8
  require_relative "super_settings/local_cache"
7
9
  require_relative "super_settings/rest_api"
8
10
  require_relative "super_settings/rack_application"
@@ -21,6 +23,8 @@ end
21
23
  module SuperSettings
22
24
  DEFAULT_REFRESH_INTERVAL = 5.0
23
25
 
26
+ @local_cache = LocalCache.new(refresh_interval: DEFAULT_REFRESH_INTERVAL)
27
+
24
28
  class << self
25
29
  # Get a setting value cast to a string.
26
30
  #
@@ -28,7 +32,7 @@ module SuperSettings
28
32
  # @param default [String] value to return if the setting value is nil
29
33
  # @return [String]
30
34
  def get(key, default = nil)
31
- val = local_cache[key]
35
+ val = context_setting(key)
32
36
  val.nil? ? default : val.to_s
33
37
  end
34
38
 
@@ -46,7 +50,7 @@ module SuperSettings
46
50
  # @param default [Integer] value to return if the setting value is nil
47
51
  # @return [Integer]
48
52
  def integer(key, default = nil)
49
- val = local_cache[key]
53
+ val = context_setting(key)
50
54
  (val.nil? ? default : val)&.to_i
51
55
  end
52
56
 
@@ -56,7 +60,7 @@ module SuperSettings
56
60
  # @param default [Numeric] value to return if the setting value is nil
57
61
  # @return [Float]
58
62
  def float(key, default = nil)
59
- val = local_cache[key]
63
+ val = context_setting(key)
60
64
  (val.nil? ? default : val)&.to_f
61
65
  end
62
66
 
@@ -66,7 +70,7 @@ module SuperSettings
66
70
  # @param default [Boolean] value to return if the setting value is nil
67
71
  # @return [Boolean]
68
72
  def enabled?(key, default = false)
69
- val = local_cache[key]
73
+ val = context_setting(key)
70
74
  Coerce.boolean(val.nil? ? default : val)
71
75
  end
72
76
 
@@ -85,7 +89,7 @@ module SuperSettings
85
89
  # @param default [Time] value to return if the setting value is nil
86
90
  # @return [Time]
87
91
  def datetime(key, default = nil)
88
- val = local_cache[key]
92
+ val = context_setting(key)
89
93
  Coerce.time(val.nil? ? default : val)
90
94
  end
91
95
 
@@ -95,36 +99,12 @@ module SuperSettings
95
99
  # @param default [Array] value to return if the setting value is nil
96
100
  # @return [Array]
97
101
  def array(key, default = nil)
98
- val = local_cache[key]
102
+ val = context_setting(key)
99
103
  val = default if val.nil?
100
104
  return nil if val.nil?
101
105
  Array(val).collect { |v| v&.to_s }
102
106
  end
103
107
 
104
- # Get setting values cast to a hash. This method can be used to cast the flat setting key/value
105
- # store into a structured data store. It uses a delimiter to define how keys are nested which
106
- # defaults to a dot.
107
- #
108
- # If, for example, you have three keys in you settings +A.B1.C1 = 1+, +A.B1.C2 = 2+, and +A.B2.C3 = 3+, the
109
- # nested structure will be:
110
- #
111
- # +{"A" => {"B1" => {"C1" => 1, "C2" => 2}, "B2" => {"C3" => 3}}}+
112
- #
113
- # This whole hash would be returned if you called +hash+ without any key. If you called it with the
114
- # key "A.B1", it would return
115
- #
116
- # +{"C1" => 1, "C2" => 2}+
117
- #
118
- # @param key [String, Symbol] the prefix patter to fetch keys for; default to returning all settings
119
- # @param default [Hash] value to return if the setting value is nil
120
- # @param delimiter [String] the delimiter to use to define nested keys in the hash; defaults to "."
121
- # @return [Hash]
122
- def structured(key = nil, default = nil, delimiter: ".", max_depth: nil)
123
- value = local_cache.structured(key, delimiter: delimiter, max_depth: max_depth)
124
- return (default || {}) if value.empty?
125
- value
126
- end
127
-
128
108
  # Create settings and update the local cache with the values. If a block is given, then the
129
109
  # value will be reverted at the end of the block. This method can be used in tests when you
130
110
  # need to inject a specific value into your settings.
@@ -148,6 +128,7 @@ module SuperSettings
148
128
  setting.save!
149
129
  local_cache.load_settings unless local_cache.loaded?
150
130
  local_cache.update_setting(setting)
131
+
151
132
  if block_given?
152
133
  yield
153
134
  end
@@ -161,6 +142,19 @@ module SuperSettings
161
142
  end
162
143
  end
163
144
 
145
+ # Define a block where settings will remain unchanged. This is useful to
146
+ # prevent settings from changing while you are in the middle of a block of
147
+ # code that depends on the settings.
148
+ def context(&block)
149
+ reset_context = Thread.current[:super_settings_context].nil?
150
+ begin
151
+ Thread.current[:super_settings_context] ||= {}
152
+ yield
153
+ ensure
154
+ Thread.current[:super_settings_context] = nil if reset_context
155
+ end
156
+ end
157
+
164
158
  # Load the settings from the database into the in memory cache.
165
159
  #
166
160
  # @return [void]
@@ -227,8 +221,23 @@ module SuperSettings
227
221
 
228
222
  private
229
223
 
230
- def local_cache
231
- @local_cache ||= LocalCache.new(refresh_interval: DEFAULT_REFRESH_INTERVAL)
224
+ attr_reader :local_cache
225
+
226
+ def current_context
227
+ Thread.current[:super_settings_context]
228
+ end
229
+
230
+ def context_setting(key)
231
+ key = key.to_s
232
+ context = current_context
233
+ if context
234
+ unless context.include?(key)
235
+ context[key] = local_cache[key]
236
+ end
237
+ context[key]
238
+ else
239
+ local_cache[key]
240
+ end
232
241
  end
233
242
  end
234
243
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: super_settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.rc3
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-06-04 00:00:00.000000000 Z
11
+ date: 2023-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -56,6 +56,9 @@ files:
56
56
  - lib/super_settings/attributes.rb
57
57
  - lib/super_settings/coerce.rb
58
58
  - lib/super_settings/configuration.rb
59
+ - lib/super_settings/context.rb
60
+ - lib/super_settings/context/rack_middleware.rb
61
+ - lib/super_settings/context/sidekiq_middleware.rb
59
62
  - lib/super_settings/controller_actions.rb
60
63
  - lib/super_settings/engine.rb
61
64
  - lib/super_settings/history_item.rb
@@ -85,11 +88,11 @@ required_ruby_version: !ruby/object:Gem::Requirement
85
88
  version: '2.5'
86
89
  required_rubygems_version: !ruby/object:Gem::Requirement
87
90
  requirements:
88
- - - ">"
91
+ - - ">="
89
92
  - !ruby/object:Gem::Version
90
- version: 1.3.1
93
+ version: '0'
91
94
  requirements: []
92
- rubygems_version: 3.2.22
95
+ rubygems_version: 3.4.12
93
96
  signing_key:
94
97
  specification_version: 4
95
98
  summary: Fast access runtime settings for a Rails application with an included UI