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 +4 -4
- data/README.md +211 -4
- data/Rakefile +4 -1
- data/lib/smart_enum/active_record_compatibility.rb +1 -1
- data/lib/smart_enum/associations.rb +9 -6
- data/lib/smart_enum/attributes.rb +1 -1
- data/lib/smart_enum/utilities.rb +54 -0
- data/lib/smart_enum/version.rb +1 -1
- data/lib/smart_enum/yaml_store.rb +1 -1
- data/lib/smart_enum.rb +3 -2
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4882a3bbae8ab52762aaa62b44e1ba9599aef5ac71c618eeb268a808a6560073
|
4
|
+
data.tar.gz: 730906218a7baa84742f09fef262f31b2f00a12be574ae35e460518e03555fa4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1c0eb912a0b9980c8926ef41ed0f198876829ae6b97756b3e7f646aa0eced3336cdadb2c2d664212805174fa8c551ec192c107e0e81ba4b6616583244f7a20f1
|
7
|
+
data.tar.gz: 3f2c302472b6a44e9a1f88360fd450604af5c1ad0c72a5830cce39a46605b27800c1e0a5c910320333366e0edaa9eebf520aaca8ad234cdfb3c3f995ad50d3ec
|
data/README.md
CHANGED
@@ -1,10 +1,32 @@
|
|
1
1
|
# SmartEnum
|
2
2
|
|
3
|
-
|
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
|
-
|
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 `
|
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.
|
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)
|
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
|
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 ||
|
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 ||
|
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 ||=
|
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.
|
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
|
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
|
data/lib/smart_enum/version.rb
CHANGED
@@ -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
|
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.
|
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] ||=
|
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:
|
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-
|
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.
|
68
|
+
rubygems_version: 2.7.4
|
68
69
|
signing_key:
|
69
70
|
specification_version: 4
|
70
71
|
summary: Enums to replace database lookup tables
|