iron-settings 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.rspec +1 -0
- data/History.txt +9 -0
- data/LICENSE +20 -0
- data/README.rdoc +133 -0
- data/Version.txt +1 -0
- data/db/settings_migration.rb +23 -0
- data/lib/iron/rake_loader.rb +6 -0
- data/lib/iron/settings.rb +161 -0
- data/lib/iron/settings/builder.rb +72 -0
- data/lib/iron/settings/class_level.rb +122 -0
- data/lib/iron/settings/cursor.rb +170 -0
- data/lib/iron/settings/db_store.rb +57 -0
- data/lib/iron/settings/db_value.rb +33 -0
- data/lib/iron/settings/entry.rb +26 -0
- data/lib/iron/settings/group.rb +102 -0
- data/lib/iron/settings/instance_level.rb +98 -0
- data/lib/iron/settings/node.rb +46 -0
- data/lib/iron/settings/root.rb +15 -0
- data/lib/iron/settings/static_store.rb +85 -0
- data/lib/iron/settings/value_store.rb +88 -0
- data/lib/tasks/settings.rake +65 -0
- data/spec/samples/static-test +4 -0
- data/spec/samples/static-test-2 +1 -0
- data/spec/settings/builder_spec.rb +48 -0
- data/spec/settings/class_level_spec.rb +46 -0
- data/spec/settings/cursor_spec.rb +96 -0
- data/spec/settings/db_store_spec.rb +12 -0
- data/spec/settings/db_value_spec.rb +58 -0
- data/spec/settings/entry_spec.rb +34 -0
- data/spec/settings/group_spec.rb +35 -0
- data/spec/settings/instance_level_spec.rb +44 -0
- data/spec/settings/settings_spec.rb +61 -0
- data/spec/settings/static_store_spec.rb +46 -0
- data/spec/settings/value_store_spec.rb +55 -0
- data/spec/spec_helper.rb +50 -0
- metadata +114 -0
@@ -0,0 +1,170 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
# Cursors handle navigating the settings hierarchy built by our Builder class, allowing
|
4
|
+
# getting and setting entry values and inspecting the hierarchy itself.
|
5
|
+
class Cursor < DslBuilder
|
6
|
+
|
7
|
+
# Start up our cursor bound to a given group in the settings hierarchy, with
|
8
|
+
# the value store holding the values for the current context.
|
9
|
+
def initialize(group, values, context = nil)
|
10
|
+
@group = group
|
11
|
+
@values = values
|
12
|
+
@context = context
|
13
|
+
end
|
14
|
+
|
15
|
+
# Provides access to the root of the hierarchy, generally not useful
|
16
|
+
# during operations... :-)
|
17
|
+
def root
|
18
|
+
@group.root
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns all entry keys at the cursor's current position, and optionally
|
22
|
+
# including all child keys. If the cursor is at a sub-group node, keys
|
23
|
+
# will be relative to that node.
|
24
|
+
def entry_keys(include_all = true)
|
25
|
+
keys = @group.entries(include_all).collect {|e| e.key }
|
26
|
+
unless @group.key.blank?
|
27
|
+
keys.collect! {|k| k.gsub(@group.key + '.', '') }
|
28
|
+
end
|
29
|
+
keys
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns all group keys
|
33
|
+
def group_keys(include_all = false)
|
34
|
+
keys = @group.entries(include_all).collect {|e| e.key }
|
35
|
+
unless @group.key.blank?
|
36
|
+
keys.collect! {|k| k.gsub(@group.key + '.', '') }
|
37
|
+
end
|
38
|
+
keys
|
39
|
+
end
|
40
|
+
|
41
|
+
# Finds the item (group or entry) in the hierarchy matching the provided
|
42
|
+
# relative key. Raises a RuntimeError on unknown keys.
|
43
|
+
def find_item(key)
|
44
|
+
item = @group
|
45
|
+
key = key.to_s
|
46
|
+
parts = key.split(/\./)
|
47
|
+
until parts.empty?
|
48
|
+
item_key = parts.shift
|
49
|
+
item = item.find_item(item_key)
|
50
|
+
raise RuntimeError.new("Unknown settings group or entry '#{item_key}' in settings path #{[@group.key,key].list_join('.')}") if item.nil?
|
51
|
+
end
|
52
|
+
item
|
53
|
+
end
|
54
|
+
|
55
|
+
# Return Settings::Entry items for entries at this cursor level and optionally below it
|
56
|
+
def find_entries(include_all = true)
|
57
|
+
@group.entries(include_all)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Array-like access to the entry value at the specified key
|
61
|
+
def [](key, &block)
|
62
|
+
item = find_item(key)
|
63
|
+
if item.group?
|
64
|
+
# Got asked for another group, so create a new cursor and do the right thing(tm)
|
65
|
+
cursor = Settings::Cursor.new(item, @values)
|
66
|
+
DslProxy::exec(cursor, &block) if block
|
67
|
+
cursor
|
68
|
+
else
|
69
|
+
item_value(item)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Array-like setter for entry values using the specified key
|
74
|
+
def []=(key, val)
|
75
|
+
item = find_item(key)
|
76
|
+
if item
|
77
|
+
@values.set_value(item.key, Settings.parse(val, item.type))
|
78
|
+
end
|
79
|
+
val
|
80
|
+
end
|
81
|
+
|
82
|
+
# Look for the next item from our current group pointer,
|
83
|
+
# returning a new cursor if the item is a sub-group, or the value
|
84
|
+
# of the requested entry if the item is a leaf in the
|
85
|
+
# hierarchy tree.
|
86
|
+
def method_missing(method, *args, &block)
|
87
|
+
method = method.to_s
|
88
|
+
query = method.ends_with?('?')
|
89
|
+
assignment = method.ends_with?('=')
|
90
|
+
method.gsub!(/[=\?]+/,'')
|
91
|
+
|
92
|
+
# Look up the item
|
93
|
+
item = @group.find_item(method)
|
94
|
+
if item.nil?
|
95
|
+
# Unknown item name, whoops.
|
96
|
+
raise RuntimeError.new("Unknown settings group or entry '#{method}' for settings path #{@group.key}")
|
97
|
+
|
98
|
+
elsif item.group?
|
99
|
+
if query
|
100
|
+
# Yes, this group exists
|
101
|
+
return true
|
102
|
+
else
|
103
|
+
# Got asked for another group, so create a new cursor and do the right thing(tm)
|
104
|
+
cursor = Settings::Cursor.new(item, @values)
|
105
|
+
DslProxy::exec(cursor, &block) if block
|
106
|
+
return cursor
|
107
|
+
end
|
108
|
+
|
109
|
+
elsif item.entry?
|
110
|
+
if query
|
111
|
+
# Return true if the given item has a (non-default) value
|
112
|
+
return item_has_value?(item)
|
113
|
+
else
|
114
|
+
if args.empty?
|
115
|
+
# No args means return the current value (or default if none)
|
116
|
+
return item_value(item)
|
117
|
+
else
|
118
|
+
# With args, we set the current value of the item (if it parses correctly)
|
119
|
+
val = Settings.parse(args.first, item.type)
|
120
|
+
@values.set_value(item.key, val)
|
121
|
+
return args.first
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Counterpart to #method_missing
|
128
|
+
def respond_to_missing?(method, include_private = false)
|
129
|
+
method = method.to_s.gsub(/[=\?]+/,'')
|
130
|
+
item = @group.find_item(method)
|
131
|
+
return !item.nil?
|
132
|
+
end
|
133
|
+
|
134
|
+
# When true, has non-default value set for the given entry
|
135
|
+
def item_has_value?(item)
|
136
|
+
return false if item.group?
|
137
|
+
@values.has_value?(item.key)
|
138
|
+
end
|
139
|
+
|
140
|
+
# Calculates the value of the given entry item given the current value store
|
141
|
+
# and item default value.
|
142
|
+
def item_value(item)
|
143
|
+
return item_default_value(item) unless item_has_value?(item)
|
144
|
+
val = @values.get_value(item.key)
|
145
|
+
Settings.restore(val, item.type)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Calculates the default value for an entry, handling callable defaults.
|
149
|
+
def item_default_value(item)
|
150
|
+
return nil if item.group? || item.default.nil?
|
151
|
+
if item.default.respond_to?(:call)
|
152
|
+
# Callable default, call in context of a root cursor, yielding our context (generally a
|
153
|
+
# model instance) to the block.
|
154
|
+
val = DslProxy.exec(Cursor.new(root, @values), @context, &(item.default))
|
155
|
+
val = Settings.parse(val, item.type)
|
156
|
+
else
|
157
|
+
val = item.default
|
158
|
+
end
|
159
|
+
Settings.restore(val, item.type)
|
160
|
+
end
|
161
|
+
|
162
|
+
def eval_in_context(text) # :nodoc:
|
163
|
+
proc = Proc.new {}
|
164
|
+
binding = proc.binding
|
165
|
+
eval(text, binding)
|
166
|
+
end
|
167
|
+
|
168
|
+
end
|
169
|
+
|
170
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
class Settings # :nodoc:
|
2
|
+
|
3
|
+
# Implements database-backed value storage for dynamic settings. This value store is appropriate for both
|
4
|
+
# class-level and instance-level settings storage. This store takes no special options on creation, however
|
5
|
+
# it is critical that the proper :reload option be set for your use-case.
|
6
|
+
#
|
7
|
+
# For class-level settings, the proper option (and default) will often be to use a
|
8
|
+
# synchronization file by specifying :reload => '<some path>' during
|
9
|
+
# initialization. This will cause the value store to touch the given file on saving changes, and
|
10
|
+
# reload values when the timestamp on the file is newer than the last reload.
|
11
|
+
#
|
12
|
+
# For example, in Rails (with long-running, multi-instance operations), this will allow you to have
|
13
|
+
# your settings loaded once on load, then once per instance when changes are saved to the database.
|
14
|
+
#
|
15
|
+
# For instance-level settings, the proper value is often :reload => false, as the values will
|
16
|
+
# be loaded on initilization of the instance, then thrown away with the instance. This will be
|
17
|
+
# ideal for Models in Rails, for example. If you have long-running instances that need reloads,
|
18
|
+
# you can use a shared timestamp as above, causing *all* instances to reload when any instance
|
19
|
+
# changes (feasible for a small number of instances or when settings do not change frequently), or
|
20
|
+
# you can set a timeout in seconds using :reload => <num seconds> to cause instances to reload
|
21
|
+
# after that time has elapsed.
|
22
|
+
class DBStore < Settings::ValueStore
|
23
|
+
|
24
|
+
def initialize(root, context, options = {})
|
25
|
+
options = {
|
26
|
+
:reload => false
|
27
|
+
}.merge(options)
|
28
|
+
@context = context
|
29
|
+
|
30
|
+
# Let our base class at it
|
31
|
+
super(root, options)
|
32
|
+
end
|
33
|
+
|
34
|
+
def load
|
35
|
+
super
|
36
|
+
|
37
|
+
# Load up the values from DB, store in internal values hash by full key
|
38
|
+
Settings::DBValue.for_context(@context).each do |val|
|
39
|
+
@values[val.full_key] = val.value
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def save
|
44
|
+
# Clear any old settings
|
45
|
+
Settings::DBValue.for_context(@context).delete_all
|
46
|
+
|
47
|
+
# Create new settings
|
48
|
+
@values.each_pair do |key, val|
|
49
|
+
Settings::DBValue.create(context: @context, full_key: key, value: val)
|
50
|
+
end
|
51
|
+
|
52
|
+
super
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Stores a given setting entry's value in the database
|
2
|
+
class Settings::DBValue < ActiveRecord::Base
|
3
|
+
|
4
|
+
# Use a non-standard table name
|
5
|
+
self.table_name = 'settings_values'
|
6
|
+
|
7
|
+
# Scopes for our values
|
8
|
+
scope :for_context, lambda {|context|
|
9
|
+
if context.is_a?(Module)
|
10
|
+
klass = context
|
11
|
+
id = nil
|
12
|
+
else
|
13
|
+
klass = context.class
|
14
|
+
id = context.id
|
15
|
+
end
|
16
|
+
where(:context_type => klass.to_s, :context_id => id)
|
17
|
+
}
|
18
|
+
|
19
|
+
# We serialize our values...
|
20
|
+
serialize :value
|
21
|
+
|
22
|
+
# Set our context
|
23
|
+
def context=(context)
|
24
|
+
if context.is_a?(ActiveRecord::Base)
|
25
|
+
self.context_type = context.class.name
|
26
|
+
self.context_id = context.id
|
27
|
+
else
|
28
|
+
self.context_type = context.to_s
|
29
|
+
self.context_id = nil
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
# Represents a leaf in our structure, has a value
|
4
|
+
class Entry < Settings::Node
|
5
|
+
|
6
|
+
attr_accessor :type, :default
|
7
|
+
|
8
|
+
def initialize(parent, type, name, default)
|
9
|
+
super(parent, name)
|
10
|
+
|
11
|
+
@type = type
|
12
|
+
@default = default.respond_to?(:call) ? default : Settings.parse(default, type)
|
13
|
+
end
|
14
|
+
|
15
|
+
def entry?
|
16
|
+
true
|
17
|
+
end
|
18
|
+
|
19
|
+
def default_value(root_cursor, context = nil)
|
20
|
+
return nil if @default.nil?
|
21
|
+
@default.respond_to?(:call) ? Settings.parse(@default.call(context), @type) : DslProxy.exec(root_cursor, @default, context)
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
# Groups contain a set of items - other groups and entries - that
|
4
|
+
# can be traversed by the Cursor to read out or set values.
|
5
|
+
class Group < Settings::Node
|
6
|
+
|
7
|
+
# Create and set up a new group with the given name
|
8
|
+
# and optional parent
|
9
|
+
def initialize(parent, name = nil)
|
10
|
+
super
|
11
|
+
@nodes = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def nodes
|
15
|
+
@nodes
|
16
|
+
end
|
17
|
+
|
18
|
+
def group?
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](key)
|
23
|
+
find_item(key)
|
24
|
+
end
|
25
|
+
|
26
|
+
# def _add_node(item)
|
27
|
+
# @nodes[item._key] =
|
28
|
+
|
29
|
+
# Add a group to our list of items, and define
|
30
|
+
# a getter to access it by name
|
31
|
+
def add_group(name)
|
32
|
+
# Add getter for the group
|
33
|
+
instance_eval <<-eos
|
34
|
+
def #{name}
|
35
|
+
find_group('#{name}')
|
36
|
+
end
|
37
|
+
eos
|
38
|
+
|
39
|
+
group = Group.new(self, name)
|
40
|
+
@nodes[name] = group
|
41
|
+
group
|
42
|
+
end
|
43
|
+
|
44
|
+
# Simply access a given group by name
|
45
|
+
def find_group(key)
|
46
|
+
group = @nodes[key.to_s]
|
47
|
+
group.is_a?(Group) ? group : nil
|
48
|
+
end
|
49
|
+
|
50
|
+
def add_entry(name, type, default = nil, &block)
|
51
|
+
default = block unless block.nil?
|
52
|
+
entry = Settings::Entry.new(self, type, name, default)
|
53
|
+
@nodes[name] = entry
|
54
|
+
entry
|
55
|
+
end
|
56
|
+
|
57
|
+
def find_entry(name)
|
58
|
+
entry = @nodes[name.to_s]
|
59
|
+
entry.is_a?(Settings::Entry) ? entry : nil
|
60
|
+
end
|
61
|
+
|
62
|
+
def get_entry_val(name)
|
63
|
+
entry = find_entry(name)
|
64
|
+
return nil unless entry
|
65
|
+
entry.value
|
66
|
+
end
|
67
|
+
|
68
|
+
def set_entry_val(name, value)
|
69
|
+
entry = find_entry(name)
|
70
|
+
return unless entry
|
71
|
+
entry.value = value
|
72
|
+
end
|
73
|
+
|
74
|
+
def find_item(key)
|
75
|
+
@nodes[key.to_s]
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns all child entries for this group, optionally recursing
|
79
|
+
# to extract sub-groups' entries as well
|
80
|
+
def entries(include_children = true)
|
81
|
+
@nodes.values.collect do |item|
|
82
|
+
if item.entry?
|
83
|
+
item
|
84
|
+
elsif include_children
|
85
|
+
item.entries(include_children)
|
86
|
+
else
|
87
|
+
[]
|
88
|
+
end
|
89
|
+
end.flatten
|
90
|
+
end
|
91
|
+
|
92
|
+
# Returns all groups that are children of this group
|
93
|
+
def groups(include_children = false)
|
94
|
+
list = @nodes.values.select {|i| i.group?}
|
95
|
+
if include_children
|
96
|
+
list += list.collect {|i| i.groups}.flatten
|
97
|
+
end
|
98
|
+
list
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
module InstanceLevel
|
4
|
+
|
5
|
+
# Will be bound to Object, provides support for defining settings structure + defaults
|
6
|
+
# at the class level for use in instances of that class. Defaults to database-backed
|
7
|
+
# dynamic storage with a 10 second reload. Requires that ActiveRecord be required
|
8
|
+
# prior to requiring this gem.
|
9
|
+
module ClassMethods
|
10
|
+
|
11
|
+
def instance_settings(options = {}, &block)
|
12
|
+
unless @settings_instance_root
|
13
|
+
@settings_instance_root = Settings::Root.new()
|
14
|
+
@options = {
|
15
|
+
:store => :dynamic
|
16
|
+
}.merge(options)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Set our default reload timing
|
20
|
+
if options[:reload].nil?
|
21
|
+
if options[:store] == :static
|
22
|
+
# Static settings generally don't need reloading
|
23
|
+
options[:reload] = false
|
24
|
+
else
|
25
|
+
# For dynamic, db-backed settings at the instance level, we use
|
26
|
+
# a 10 second timeout by default
|
27
|
+
options[:reload] = 10
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Save off our options
|
32
|
+
@settings_instance_options = options
|
33
|
+
|
34
|
+
# This class now need settings support at the instance level
|
35
|
+
include InstanceMethods
|
36
|
+
|
37
|
+
# Add our save hook if the class is an ActiveRecord model
|
38
|
+
if defined?(ActiveRecord) && self < ActiveRecord::Base
|
39
|
+
after_save :settings_save!
|
40
|
+
end
|
41
|
+
|
42
|
+
# Construct a builder and do the right thing
|
43
|
+
builder = Settings::Builder.new(@settings_instance_root)
|
44
|
+
builder.define(&block) if block
|
45
|
+
builder
|
46
|
+
end
|
47
|
+
|
48
|
+
def settings_instance_options
|
49
|
+
@settings_instance_options
|
50
|
+
end
|
51
|
+
|
52
|
+
def settings_instance_root
|
53
|
+
@settings_instance_root
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
# Set of methods that all instances with instance_settings set will share
|
59
|
+
module InstanceMethods
|
60
|
+
|
61
|
+
# Access settings at instance level
|
62
|
+
def settings(&block)
|
63
|
+
# Ensure we have a value store
|
64
|
+
unless @settings_values
|
65
|
+
settings_reset!
|
66
|
+
end
|
67
|
+
|
68
|
+
# Set up for use, create a cursor to read/write, and we're good to go
|
69
|
+
@settings_values.reload_if_needed
|
70
|
+
cursor = Settings::Cursor.new(self.class.settings_instance_root, @settings_values, self)
|
71
|
+
DslProxy::exec(cursor, &block) if block
|
72
|
+
cursor
|
73
|
+
end
|
74
|
+
|
75
|
+
def settings_save!
|
76
|
+
@settings_values.save if @settings_values
|
77
|
+
end
|
78
|
+
|
79
|
+
# Throw out any unsaved changes
|
80
|
+
def settings_reset!
|
81
|
+
# Create our value store
|
82
|
+
opts = self.class.settings_instance_options
|
83
|
+
@settings_values = opts[:store] == :static ?
|
84
|
+
Settings::StaticStore.new(self.class.settings_instance_root, opts) :
|
85
|
+
Settings::DBStore.new(self.class.settings_instance_root, self, opts)
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
89
|
+
|
90
|
+
# Install hooks
|
91
|
+
def self.included(base)
|
92
|
+
# Add our class methods
|
93
|
+
base.extend(ClassMethods)
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|