smart_enum 1.0.0 → 2.0.0

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 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