smart_enum 1.0.0 → 2.0.0

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: 5c6d8541665431df0b385213a9e959b327b6b6ca6a9f436fece9b37390d776c4
4
- data.tar.gz: 4b0430c1c890ac4ca89c2ebf90d58dd339b3220d6514388df86de385489d682e
3
+ metadata.gz: 4882a3bbae8ab52762aaa62b44e1ba9599aef5ac71c618eeb268a808a6560073
4
+ data.tar.gz: 730906218a7baa84742f09fef262f31b2f00a12be574ae35e460518e03555fa4
5
5
  SHA512:
6
- metadata.gz: 48851031b733fce0d63453c6155c97e72bc4a03b4e889c463fc17612cbddc0d392cec6af13b82153125fc21bb4d1ae80f73278a51ed02f7b53f497b57ab3827b
7
- data.tar.gz: bd7d182e11e7dd99ced0db64fae7a0aa8d79f9ea8cae1a8b4bb785f233c846ad961cef5e690ef172cd279b16d6307729dbcb9f8bb4880236e56984831f1038a3
6
+ metadata.gz: 1c0eb912a0b9980c8926ef41ed0f198876829ae6b97756b3e7f646aa0eced3336cdadb2c2d664212805174fa8c551ec192c107e0e81ba4b6616583244f7a20f1
7
+ data.tar.gz: 3f2c302472b6a44e9a1f88360fd450604af5c1ad0c72a5830cce39a46605b27800c1e0a5c910320333366e0edaa9eebf520aaca8ad234cdfb3c3f995ad50d3ec
data/README.md CHANGED
@@ -1,10 +1,32 @@
1
1
  # SmartEnum
2
2
 
3
- Create Enum values to replace database lookup tables.
3
+ SmartEnum provides a way to manage, relate and query a certain kind of "lookup"
4
+ data that many applications need. It is most useful when the data looks
5
+ relatively relational and wants to associate with other lookup data or with
6
+ persisted data.
7
+
8
+ ## Rationale
9
+
10
+ Consider a multitenant SAAS rails application that needs to model its list of
11
+ subscription plans. Customer accounts are associated with a given plan and
12
+ many parts of the application's behavior change based on the plan of the
13
+ customer currently being handled. The path of least resistance is to treat
14
+ `Plan` as a persisted relational model: make a `plans` table, add
15
+ `Customer.belongs_to :plan`, and create a migration to create the table and
16
+ populate the list of plans you want to make available. But this strategy
17
+ becomes problematic once an application grows beyond a single database.
18
+ Changes to the list of plans or the `Plan` model often require a database
19
+ migration that must be carefully synchronized across multiple shards. You also
20
+ risk identifiers going out of sync: there are a number of ways that you can end
21
+ up in a situation where shards A and B do not agree on what `plan_id=3` refers
22
+ to. All of this can be mitigated, but it points to the fact that this type of
23
+ information is *part of your codebase*, and it should be stored alongside the
24
+ code. SmartEnum provides a scheme to do this while preserving some of the
25
+ conveniences of using persisted data, like model associations and a query DSL.
4
26
 
5
27
  ## Installation
6
28
 
7
- SmartEnum requires ruby 2.4.0 or above.
29
+ SmartEnum requires ruby 2.4.0 or above. It integrates with rails but does not require it to function.
8
30
 
9
31
  Add this line to your application's Gemfile:
10
32
 
@@ -22,11 +44,196 @@ Or install it yourself as:
22
44
 
23
45
  ## Usage
24
46
 
25
- TBD
47
+ ### Simple example (without rails or yaml files)
48
+
49
+ ```ruby
50
+
51
+ class Plan < SmartEnum
52
+ attribute :id, Integer
53
+ attribute :name, String
54
+ attribute :user_limit, Integer
55
+ attribute :monthly_cost_cents, Integer
56
+ end
57
+
58
+ Plan.register_values([
59
+ {id: 1, name: 'Basic', user_limit: 1},
60
+ {id: 2, name: 'Premium', user_limit: 5}
61
+ ])
62
+
63
+ Plan.find(1).name
64
+ # => "Basic"
65
+ Plan.find_by(name: 'Premium').id
66
+ # => 2
67
+ ```
68
+
69
+ ### Associating with other SmartEnum models and Rails models
70
+
71
+ The folowing
72
+ [macros](https://github.com/ShippingEasy/smart_enum/blob/master/lib/smart_enum/associations.rb)
73
+ are provided:
74
+
75
+ - `has_many_enums`
76
+ - `has_one_enum`
77
+ - `has_one_enum_through`
78
+ - `has_many_enums_through`
79
+ - `belongs_to_enum`
80
+
81
+ The target of these macros must be a SmartEnum class, but any class can be the
82
+ source.
83
+
84
+ ```ruby
85
+
86
+ class UserLimitPolicy < SmartEnum
87
+ attribute :id, String
88
+ attribute :max_count, Integer
89
+
90
+ def unlimited?
91
+ max_count == nil
92
+ end
93
+ end
94
+
95
+ class Plan < SmartEnum
96
+ attribute :id, String
97
+ attribute :name, String
98
+ attribute :user_limit_policy_id, String
99
+ belongs_to_enum :user_limit_policy
100
+ end
101
+
102
+ class Customer < ApplicationRecord
103
+ extend SmartEnum::Associations
104
+ belongs_to_enum :plan
105
+ end
106
+
107
+
108
+ UserLimitPolicy.register_values([
109
+ {id: 'five_users', max_count: 5},
110
+ {id: 'unlimited'}
111
+ ])
112
+
113
+ Plan.register_values([
114
+ {id: 'basic', name: 'Basic', user_limit_policy_id: 'five_users'},
115
+ {id: 'prem', name: 'Premium', user_limit_policy_id: 'unlimited'}
116
+ ])
117
+
118
+ # Associate among SmartEnum classes
119
+ Plan.find(2).user_limit_policy.unlimited?
120
+ # => true
121
+ Plan.find(1).user_limit_policy.unlimited?
122
+ # => false
123
+
124
+ # Associate with persisted models
125
+ Customer.new(plan_id: 'premium').plan.user_limit_policy.unlimited?
126
+ # => true
127
+
128
+ Plan.find(1).name
129
+ # => "Basic"
130
+ Plan.find_by(name: 'Premium').id
131
+ # => 2
132
+ ```
133
+
134
+ ### Store data in YAML files
135
+
136
+ This is the recommended way to manage data for convenience and compatibility
137
+ with rails autoloading.
138
+
139
+ ```ruby
140
+ # config/initializers/000_smart_enum.rb
141
+ SmartEnum::YamlStore.data_root = Rails.root.join("data/lookups")
142
+ ```
143
+
144
+ ```yaml
145
+ # data/lookups/plans.yml
146
+ ---
147
+ - id: 1
148
+ name: Basic
149
+ - id: 2
150
+ name: Premium
151
+ ```
152
+
153
+ ```ruby
154
+ # app/models/plan.rb
155
+ class Plan < SmartEnum
156
+ attribute :id, String
157
+ attribute :name, String
158
+
159
+ # infers yaml location by name and loads all data
160
+ register_values_from_file!
161
+ end
162
+ ```
163
+
164
+ ### Custom type coercion
165
+
166
+ SmartEnum attributes are typechecked on initialization, so the following will fail:
167
+ ```ruby
168
+ class Package < SmartEnum
169
+ attribute :id, Integer
170
+ attribute :length, BigDecimal
171
+ attribute :width, BigDecimal
172
+ attribute :height, BigDecimal
173
+ end
174
+
175
+ Package.register_values([{id: 1, length: 1, width: 2, height: 3}])
176
+ # RuntimeError (Attribute :length passed 1:Integer in initializer, but needs [BigDecimal] and has no coercer)
177
+ ```
178
+
179
+ One option here is to use attribute coercers:
180
+
181
+ ```ruby
182
+ class Package < SmartEnum
183
+ attribute :id, Integer
184
+ attribute :length, BigDecimal, coercer: -> arg { BigDecimal(arg) }
185
+ attribute :width, BigDecimal, coercer: -> arg { BigDecimal(arg) }
186
+ attribute :height, BigDecimal, coercer: -> arg { BigDecimal(arg) }
187
+ end
188
+
189
+ Package.register_values([{id: 1, length: 1, width: 2, height: 3}])
190
+ Package.find(1).length.class
191
+ # => BigDecimal
192
+ ```
193
+
194
+ ### Single Table Inheritence
195
+
196
+ SmartEnum supports a mechanism that works like single table inheritence in
197
+ rails: a collection of registered records can have different classes depending
198
+ on the content of each record's `type` column:
199
+
200
+ ```ruby
201
+ class Vehicle < SmartEnum
202
+ attribute :id, Integer
203
+ attribute :type, String
204
+ attribute :make, String
205
+ attribute :model, String
206
+
207
+ def display_name
208
+ "#{make} #{model}"
209
+ end
210
+ end
211
+
212
+ class Car < Vehicle
213
+ def requires_commercial_license?
214
+ false
215
+ end
216
+ end
217
+
218
+ class SemiTruck < Vehicle
219
+ def requires_commercial_license?
220
+ true
221
+ end
222
+ end
223
+
224
+ data = [
225
+ {id: 1, type: 'Car', make: 'Toyota', model: 'Camry'},
226
+ {id: 2, type: 'SemiTruck', make: 'Freightliner', model: 'Cascadia'}
227
+ ]
228
+ Vehicle.register_values(data, detect_sti_types: true)
229
+ Vehicle.all.map {|v| [v.display_name, v.requires_commercial_license?]}
230
+ # => [["Toyota Camry", false], ["Freightliner Cascadia", true]]
231
+ ```
232
+
26
233
 
27
234
  ## Development
28
235
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
236
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
30
237
 
31
238
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
239
 
data/Rakefile CHANGED
@@ -3,6 +3,9 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
 
6
- RSpec::Core::RakeTask.new(:spec)
6
+ RSpec::Core::RakeTask.new(:spec_noar) { |t| t.exclude_pattern = "spec/active_record_compatibility_spec.rb" }
7
+ RSpec::Core::RakeTask.new(:spec_ar) { |t| t.pattern = "spec/active_record_compatibility_spec.rb" }
8
+
9
+ task spec: [:spec_noar, :spec_ar]
7
10
 
8
11
  task :default => :spec
@@ -115,7 +115,7 @@ class SmartEnum
115
115
  # find_by(id: '1', key: :blah)
116
116
  # even when types differ like we can in ActiveRecord.
117
117
  def cast_query_attrs(raw_attrs)
118
- raw_attrs.symbolize_keys.each_with_object({}) do |(k, v), new_attrs|
118
+ SmartEnum::Utilities.symbolize_hash_keys(raw_attrs).each_with_object({}) do |(k, v), new_attrs|
119
119
  if v.instance_of?(Array)
120
120
  fail "SmartEnum can't query with array arguments yet. Got #{raw_attrs.inspect}"
121
121
  end
@@ -40,7 +40,10 @@ class SmartEnum
40
40
  enum_associations[association_name] = association
41
41
 
42
42
  define_method(association_name) do
43
- public_send(association.through_association).try(association.association_method)
43
+ intermediate = public_send(association.through_association)
44
+ if intermediate
45
+ intermediate.public_send(association.association_method)
46
+ end
44
47
  end
45
48
  end
46
49
 
@@ -77,7 +80,7 @@ class SmartEnum
77
80
 
78
81
  if generate_writer
79
82
  define_method("#{association_name}=") do |value|
80
- self.public_send(fk_writer_name, value.try(:id))
83
+ self.public_send(fk_writer_name, value&.id)
81
84
  end
82
85
  end
83
86
  end
@@ -104,11 +107,11 @@ class SmartEnum
104
107
  end
105
108
 
106
109
  def class_name
107
- @class_name ||= (class_name_option || association_name.to_s.classify).to_s
110
+ @class_name ||= (class_name_option || SmartEnum::Utilities.classify(association_name)).to_s
108
111
  end
109
112
 
110
113
  def foreign_key
111
- @foreign_key ||= (foreign_key_option || association_name.to_s.foreign_key).to_sym
114
+ @foreign_key ||= (foreign_key_option || SmartEnum::Utilities.foreign_key(association_name)).to_sym
112
115
  end
113
116
 
114
117
  def generated_method_name
@@ -116,7 +119,7 @@ class SmartEnum
116
119
  end
117
120
 
118
121
  def association_class
119
- @association_class ||= class_name.constantize.tap{|klass|
122
+ @association_class ||= SmartEnum::Utilities.constantize(class_name).tap{|klass|
120
123
  ::SmartEnum::Associations.__assert_enum(klass)
121
124
  }
122
125
  end
@@ -128,7 +131,7 @@ class SmartEnum
128
131
  begin
129
132
  return foreign_key_option.to_sym if foreign_key_option
130
133
  if owner_class.name
131
- owner_class.name.foreign_key.to_sym
134
+ SmartEnum::Utilities.foreign_key(owner_class.name).to_sym
132
135
  else
133
136
  raise "You must specify the foreign_key option when using a 'has_*' association on an anoymous class"
134
137
  end
@@ -79,7 +79,7 @@ class SmartEnum
79
79
  if block_given?
80
80
  fail "Block passed, but it would be ignored"
81
81
  end
82
- init_opts = opts.symbolize_keys
82
+ init_opts = ::SmartEnum::Utilities.symbolize_hash_keys(opts)
83
83
  if self.class.attribute_set.empty?
84
84
  fail "no attributes defined for #{self.class}"
85
85
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmartEnum
4
+ module Utilities
5
+ def self.symbolize_hash_keys(original_hash)
6
+ return original_hash if original_hash.each_key.all?{|key| Symbol === key }
7
+ symbolized_hash = {}
8
+ original_hash.each_key do |key|
9
+ symbolized_hash[key.to_sym] = original_hash[key]
10
+ end
11
+ symbolized_hash
12
+ end
13
+
14
+ def self.constantize(string)
15
+ Object.const_get(string)
16
+ end
17
+
18
+ def self.foreign_key(string)
19
+ singularize(tableize(string)) + "_id"
20
+ end
21
+
22
+ def self.singularize(string)
23
+ string.to_s.chomp("s")
24
+ end
25
+
26
+ def self.tableize(string)
27
+ underscore(string) + "s"
28
+ end
29
+
30
+ def self.classify(string)
31
+ singularize(camelize(string))
32
+ end
33
+
34
+ # Convert snake case string to camelcase string.
35
+ # Adapted from https://github.com/jeremyevans/sequel/blob/5.10.0/lib/sequel/model/inflections.rb#L103
36
+ def self.camelize(string)
37
+ string.to_s
38
+ .gsub(/\/(.?)/){|x| "::#{x[-1..-1].upcase unless x == '/'}"}
39
+ .gsub(/(^|_)(.)/){|x| x[-1..-1].upcase}
40
+ end
41
+
42
+ # Adapted from
43
+ # https://github.com/jeremyevans/sequel/blob/5.10.0/lib/sequel/model/inflections.rb#L147-L148
44
+ def self.underscore(string)
45
+ string
46
+ .to_s
47
+ .gsub(/::/, '/')
48
+ .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
49
+ .gsub(/([a-z\d])([A-Z])/,'\1_\2')
50
+ .tr("-", "_")
51
+ .downcase
52
+ end
53
+ end
54
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SmartEnum
4
- VERSION = "1.0.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -12,7 +12,7 @@ class SmartEnum
12
12
  raise "Cannot infer data file for anonymous class"
13
13
  end
14
14
 
15
- filename = "#{self.name.tableize}.yml"
15
+ filename = "#{SmartEnum::Utilities.tableize(self.name)}.yml"
16
16
  file_path = File.join(SmartEnum::YamlStore.data_root, filename)
17
17
  values = YAML.load_file(file_path)
18
18
  register_values(values, self, detect_sti_types: true)
data/lib/smart_enum.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "smart_enum/version"
4
4
  require "smart_enum/associations"
5
5
  require "smart_enum/attributes"
6
+ require "smart_enum/utilities"
6
7
 
7
8
  # A class used to build in-memory graphs of "lookup" objects that are
8
9
  # long-lived and can associate among themselves or ActiveRecord instances.
@@ -118,7 +119,7 @@ class SmartEnum
118
119
 
119
120
  def self.register_values(values, enum_type=self, detect_sti_types: false)
120
121
  values.each do |raw_attrs|
121
- _deferred_attr_hashes << raw_attrs.symbolize_keys.merge(enum_type: enum_type, detect_sti_types: detect_sti_types)
122
+ _deferred_attr_hashes << SmartEnum::Utilities.symbolize_hash_keys(raw_attrs).merge(enum_type: enum_type, detect_sti_types: detect_sti_types)
122
123
  end
123
124
  @_deferred_values_present = true
124
125
  end
@@ -131,7 +132,7 @@ class SmartEnum
131
132
  fail EnumLocked.new(enum_type) if enum_locked?
132
133
  type_attr_val = attrs[DEFAULT_TYPE_ATTR_STR] || attrs[DEFAULT_TYPE_ATTR_SYM]
133
134
  klass = if type_attr_val && detect_sti_types
134
- _constantize_cache[type_attr_val] ||= type_attr_val.constantize
135
+ _constantize_cache[type_attr_val] ||= SmartEnum::Utilities.constantize(type_attr_val)
135
136
  else
136
137
  enum_type
137
138
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_enum
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Brasic
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2018-07-09 00:00:00.000000000 Z
12
+ date: 2018-07-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -41,6 +41,7 @@ files:
41
41
  - lib/smart_enum/associations.rb
42
42
  - lib/smart_enum/attributes.rb
43
43
  - lib/smart_enum/monetize_interop.rb
44
+ - lib/smart_enum/utilities.rb
44
45
  - lib/smart_enum/version.rb
45
46
  - lib/smart_enum/yaml_store.rb
46
47
  - smart_enum.gemspec
@@ -64,7 +65,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
64
65
  version: '0'
65
66
  requirements: []
66
67
  rubyforge_project:
67
- rubygems_version: 2.7.2
68
+ rubygems_version: 2.7.4
68
69
  signing_key:
69
70
  specification_version: 4
70
71
  summary: Enums to replace database lookup tables