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