smart_enum 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +8 -0
- data/lib/smart_enum/active_record_compatibility.rb +173 -0
- data/lib/smart_enum/associations.rb +153 -0
- data/lib/smart_enum/attributes.rb +144 -0
- data/lib/smart_enum/monetize_interop.rb +48 -0
- data/lib/smart_enum/version.rb +5 -0
- data/lib/smart_enum/yaml_store.rb +32 -0
- data/lib/smart_enum.rb +161 -0
- data/smart_enum.gemspec +27 -0
- metadata +71 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8033b630c233ee1fb2ffb6edc375cd8923fcaf123b79f0a7cfeb59dfeeb2038f
|
4
|
+
data.tar.gz: a8cbe3f6fab587af7694090fefdd3a0bc572f09b550f05e0feb4c9f976f84e46
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: '09c09faf8bf6df24cdb21440a28927da8de576f226c4022b6b8c58698af92395b004a97aa707e321e0923de19caad8401ce7ce04c3be75ee6d602cb7738ea7a3'
|
7
|
+
data.tar.gz: 4f100a58b11ef870bdabe07fbe3053fdb51109ec981f0cfbac68b8bae4dd25060b5a1600c94861a2b06f392cc88dfebd1ed4ecf12c39222e34c682db4f3a2446
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 ShippingEasy
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# SmartEnum
|
2
|
+
|
3
|
+
Create Enum values to replace database lookup tables.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'smart_enum'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install smart_enum
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
TBD
|
24
|
+
|
25
|
+
## Development
|
26
|
+
|
27
|
+
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.
|
28
|
+
|
29
|
+
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).
|
30
|
+
|
31
|
+
## Contributing
|
32
|
+
|
33
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ShippingEasy/smart_enum.
|
34
|
+
|
35
|
+
|
36
|
+
## License
|
37
|
+
|
38
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
39
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bigdecimal'
|
4
|
+
require "active_record"
|
5
|
+
|
6
|
+
# Methods to make SmartEnum models work in contexts like views where rails
|
7
|
+
# expects ActiveRecord instances.
|
8
|
+
class SmartEnum
|
9
|
+
module ActiveRecordCompatibility
|
10
|
+
def self.included(base)
|
11
|
+
base.include(ActiveModel::Serialization)
|
12
|
+
base.extend(ActiveModel::Naming)
|
13
|
+
base.extend(ClassMethods)
|
14
|
+
base.extend(QueryMethods)
|
15
|
+
end
|
16
|
+
|
17
|
+
module ClassMethods
|
18
|
+
ID = "id"
|
19
|
+
def primary_key
|
20
|
+
ID
|
21
|
+
end
|
22
|
+
|
23
|
+
def reset_column_information
|
24
|
+
# no-op for legacy migration compatability
|
25
|
+
end
|
26
|
+
|
27
|
+
# Used in AR polymorphic associations. Returns the base of this class' SmartEnum STI tree.
|
28
|
+
def base_class
|
29
|
+
unless self < ::SmartEnum
|
30
|
+
raise ActiveRecordError, "#{name} doesn't belong in a hierarchy descending from SmartEnum"
|
31
|
+
end
|
32
|
+
|
33
|
+
if superclass == ::SmartEnum
|
34
|
+
self
|
35
|
+
else
|
36
|
+
superclass.base_class
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_key
|
42
|
+
[id]
|
43
|
+
end
|
44
|
+
|
45
|
+
def _read_attribute(attribute_name)
|
46
|
+
attributes.fetch(attribute_name.to_sym)
|
47
|
+
end
|
48
|
+
|
49
|
+
def destroyed?
|
50
|
+
false
|
51
|
+
end
|
52
|
+
|
53
|
+
def new_record?
|
54
|
+
false
|
55
|
+
end
|
56
|
+
|
57
|
+
def marked_for_destruction?
|
58
|
+
false
|
59
|
+
end
|
60
|
+
|
61
|
+
def persisted?
|
62
|
+
true
|
63
|
+
end
|
64
|
+
|
65
|
+
# Simulate ActiveRecord Query API
|
66
|
+
module QueryMethods
|
67
|
+
def where(uncast_attrs)
|
68
|
+
attrs = cast_query_attrs(uncast_attrs)
|
69
|
+
all.select do |instance|
|
70
|
+
instance.attributes.slice(*attrs.keys) == attrs
|
71
|
+
end.tap(&:freeze)
|
72
|
+
end
|
73
|
+
|
74
|
+
def find(id, raise_on_missing: true)
|
75
|
+
self[cast_primary_key(id)].tap do |result|
|
76
|
+
if !result && raise_on_missing
|
77
|
+
fail ActiveRecord::RecordNotFound.new("Couldn't find #{self} with 'id'=#{id}")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def find_by(uncast_attrs)
|
83
|
+
attrs = cast_query_attrs(uncast_attrs)
|
84
|
+
if attrs.size == 1 && attrs.has_key?(:id)
|
85
|
+
return find(attrs[:id], raise_on_missing: false)
|
86
|
+
end
|
87
|
+
all.detect do |instance|
|
88
|
+
instance.attributes.slice(*attrs.keys) == attrs
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def find_by!(attrs)
|
93
|
+
find_by(attrs).tap do |result|
|
94
|
+
if !result
|
95
|
+
fail ActiveRecord::RecordNotFound.new("Couldn't find #{self} with #{attrs.inspect}")
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def none
|
101
|
+
[]
|
102
|
+
end
|
103
|
+
|
104
|
+
def all
|
105
|
+
values
|
106
|
+
end
|
107
|
+
|
108
|
+
STRING = [String].freeze
|
109
|
+
SYMBOL = [Symbol].freeze
|
110
|
+
BOOLEAN = [TrueClass, FalseClass].freeze
|
111
|
+
INTEGER = [Integer].freeze
|
112
|
+
BIG_DECIMAL = [BigDecimal].freeze
|
113
|
+
# Ensure that the attrs we query by are compatible with the internal
|
114
|
+
# types, casting where possible. This allows us to e.g.
|
115
|
+
# find_by(id: '1', key: :blah)
|
116
|
+
# even when types differ like we can in ActiveRecord.
|
117
|
+
def cast_query_attrs(raw_attrs)
|
118
|
+
raw_attrs.symbolize_keys.each_with_object({}) do |(k, v), new_attrs|
|
119
|
+
if v.instance_of?(Array)
|
120
|
+
fail "SmartEnum can't query with array arguments yet. Got #{raw_attrs.inspect}"
|
121
|
+
end
|
122
|
+
if (attr_def = attribute_set[k])
|
123
|
+
if attr_def.types.any?{|type| v.instance_of?(type) }
|
124
|
+
# No need to cast
|
125
|
+
new_attrs[k] = v
|
126
|
+
elsif (v.nil? && attr_def.types != BOOLEAN)
|
127
|
+
# Querying by nil is a legit case, unless the type is boolean
|
128
|
+
new_attrs[k] = v
|
129
|
+
elsif attr_def.types == STRING
|
130
|
+
new_attrs[k] = String(v)
|
131
|
+
elsif attr_def.types == INTEGER
|
132
|
+
if v.instance_of?(String) && v.empty? # support querying by (id: '')
|
133
|
+
new_attrs[k] = nil
|
134
|
+
else
|
135
|
+
new_attrs[k] = Integer(v)
|
136
|
+
end
|
137
|
+
elsif attr_def.types == BOOLEAN
|
138
|
+
# TODO should we treat "f", 0, etc as false?
|
139
|
+
new_attrs[k] = !!v
|
140
|
+
elsif attr_def.types == BIG_DECIMAL
|
141
|
+
new_attrs[k] = BigDecimal(v)
|
142
|
+
else
|
143
|
+
fail "Passed illegal query arguments for #{k}: #{v} is a #{v.class}, need #{attr_def.types} or a cast rule (got #{raw_attrs.inspect})"
|
144
|
+
end
|
145
|
+
else
|
146
|
+
fail "#{k} is not an attribute of #{self}! (got #{raw_attrs.inspect})"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Given an "id-like" parameter (usually the first argument to find),
|
152
|
+
# cast it to the same type used to index the PK lookup hash.
|
153
|
+
def cast_primary_key(id_input)
|
154
|
+
return if id_input == nil
|
155
|
+
id_attribute = attribute_set[:id]
|
156
|
+
fail "no :id attribute defined on #{self}" if !id_attribute
|
157
|
+
types = id_attribute.types
|
158
|
+
# if the type is already compatible, return it.
|
159
|
+
return id_input if types.any? { |t| id_input.instance_of?(t) }
|
160
|
+
case types
|
161
|
+
when INTEGER then Integer(id_input)
|
162
|
+
when STRING then id_input.to_s
|
163
|
+
when SYMBOL then id_input.to_s.to_sym
|
164
|
+
else
|
165
|
+
fail "incompatible type: got #{id_input.class}, need #{types.inspect} or something castable to that"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# automatically enable when this file is loaded
|
172
|
+
include ActiveRecordCompatibility
|
173
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Macros for registring associations with other SmartEnum models
|
4
|
+
class SmartEnum
|
5
|
+
module Associations
|
6
|
+
def has_many_enums(association_name, class_name: nil, as: nil, foreign_key: nil, through: nil, source: nil)
|
7
|
+
association_name = association_name.to_sym
|
8
|
+
if through
|
9
|
+
return has_many_enums_through(association_name, through, source: source)
|
10
|
+
end
|
11
|
+
|
12
|
+
association = HasAssociation.new(self, association_name, class_name: class_name, as: as, foreign_key: foreign_key)
|
13
|
+
enum_associations[association_name] = association
|
14
|
+
|
15
|
+
define_method(association.generated_method_name) do
|
16
|
+
association.association_class.values.select{|instance|
|
17
|
+
instance.attributes[association.foreign_key] == self.id
|
18
|
+
}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def has_one_enum(association_name, class_name: nil, foreign_key: nil, through: nil, source: nil)
|
23
|
+
if through
|
24
|
+
return has_one_enum_through(association_name, through, source: source)
|
25
|
+
end
|
26
|
+
|
27
|
+
association_name = association_name.to_sym
|
28
|
+
association = HasAssociation.new(self, association_name, class_name: class_name, foreign_key: foreign_key)
|
29
|
+
enum_associations[association_name] = association
|
30
|
+
|
31
|
+
define_method(association_name) do
|
32
|
+
association.association_class.values.detect{|instance|
|
33
|
+
instance.attributes[association.foreign_key] == self.id
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def has_one_enum_through(association_name, through_association, source: nil)
|
39
|
+
association = ThroughAssociation.new(association_name, through_association, source: source)
|
40
|
+
enum_associations[association_name] = association
|
41
|
+
|
42
|
+
define_method(association_name) do
|
43
|
+
public_send(association.through_association).try(association.association_method)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def has_many_enums_through(association_name, through_association, source: nil)
|
48
|
+
association = ThroughAssociation.new(association_name, through_association, source: source)
|
49
|
+
enum_associations[association_name] = association
|
50
|
+
|
51
|
+
define_method(association_name) do
|
52
|
+
public_send(association.through_association).
|
53
|
+
flat_map(&association.association_method).compact.tap(&:freeze)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def belongs_to_enum(association_name, class_name: nil, foreign_key: nil)
|
58
|
+
association_name = association_name.to_sym
|
59
|
+
association = Association.new(self, association_name, class_name: class_name, foreign_key: foreign_key)
|
60
|
+
enum_associations[association_name] = association
|
61
|
+
|
62
|
+
define_method(association_name) do
|
63
|
+
id_to_find = self.public_send(association.foreign_key)
|
64
|
+
association.association_class[id_to_find]
|
65
|
+
end
|
66
|
+
|
67
|
+
fk_writer_name = "#{association.foreign_key}=".to_sym
|
68
|
+
|
69
|
+
generate_writer = instance_methods.include?(fk_writer_name) || (
|
70
|
+
# ActiveRecord may not have generated the FK writer method yet.
|
71
|
+
# We'll assume that it will get a writer if it has a column with the same name.
|
72
|
+
defined?(ActiveRecord::Base) &&
|
73
|
+
self <= ActiveRecord::Base &&
|
74
|
+
self.respond_to?(:column_names) &&
|
75
|
+
self.column_names.include?(association.foreign_key.to_s)
|
76
|
+
)
|
77
|
+
|
78
|
+
if generate_writer
|
79
|
+
define_method("#{association_name}=") do |value|
|
80
|
+
self.public_send(fk_writer_name, value.try(:id))
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.__assert_enum(klass)
|
86
|
+
unless klass <= SmartEnum
|
87
|
+
fail "enum associations can only associate to classes which descend from SmartEnum. #{klass} does not."
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def enum_associations
|
92
|
+
@enum_associations ||= {}
|
93
|
+
end
|
94
|
+
|
95
|
+
class Association
|
96
|
+
attr_reader :owner_class, :association_name, :class_name_option, :as_option, :foreign_key_option
|
97
|
+
|
98
|
+
def initialize(owner_class, association_name, class_name: nil, as: nil, foreign_key: nil)
|
99
|
+
@owner_class = owner_class
|
100
|
+
@association_name = association_name.to_sym
|
101
|
+
@class_name_option = class_name
|
102
|
+
@as_option = as
|
103
|
+
@foreign_key_option = foreign_key
|
104
|
+
end
|
105
|
+
|
106
|
+
def class_name
|
107
|
+
@class_name ||= (class_name_option || association_name.to_s.classify).to_s
|
108
|
+
end
|
109
|
+
|
110
|
+
def foreign_key
|
111
|
+
@foreign_key ||= (foreign_key_option || association_name.to_s.foreign_key).to_sym
|
112
|
+
end
|
113
|
+
|
114
|
+
def generated_method_name
|
115
|
+
@generated_method_name ||= (as_option || association_name).to_sym
|
116
|
+
end
|
117
|
+
|
118
|
+
def association_class
|
119
|
+
@association_class ||= class_name.constantize.tap{|klass|
|
120
|
+
::SmartEnum::Associations.__assert_enum(klass)
|
121
|
+
}
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
class HasAssociation < Association
|
126
|
+
def foreign_key
|
127
|
+
@foreign_key ||=
|
128
|
+
begin
|
129
|
+
return foreign_key_option.to_sym if foreign_key_option
|
130
|
+
if owner_class.name
|
131
|
+
owner_class.name.foreign_key.to_sym
|
132
|
+
else
|
133
|
+
raise "You must specify the foreign_key option when using a 'has_*' association on an anoymous class"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
class ThroughAssociation
|
140
|
+
attr_reader :association_name, :through_association, :source_option
|
141
|
+
|
142
|
+
def initialize(association_name, through_association, source: nil)
|
143
|
+
@association_name = association_name
|
144
|
+
@through_association = through_association.to_sym
|
145
|
+
@source_option = source
|
146
|
+
end
|
147
|
+
|
148
|
+
def association_method
|
149
|
+
@association_method ||= (source_option || association_name)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# A simple replacement for Virtus.
|
4
|
+
# - Objects are currently readonly once initialized.
|
5
|
+
# - Initialization args either match their type annotation or are nil.
|
6
|
+
# - Explicit coercion is supported with the :coercer option.
|
7
|
+
# - Booleans have special handling, they get predicate methods and get
|
8
|
+
# automatic nil => false casting.
|
9
|
+
# - Child classes automatically inherit parents' attribute set.
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# class Foo
|
14
|
+
# include SmartEnum::Attributes
|
15
|
+
# attribute :id, Integer
|
16
|
+
# attribute :enabled, Boolean
|
17
|
+
# attribute :created_at, Time, coercer: ->(arg) { Time.parse(arg) }
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# Foo.new(id: 1, created_at: '2016-1-1')
|
21
|
+
# # => #<Foo:0x007f970a090760 @attributes={:id=>1, :created_at=>"2016-01-01T00:00:00.000-06:00", :enabled=>false}}>
|
22
|
+
# Foo.new(id: 1, created_at: 123)
|
23
|
+
# # TypeError: no implicit conversion of 123 into String
|
24
|
+
# Foo.new(id: 1, enabled: true).enabled?
|
25
|
+
# # => true
|
26
|
+
#
|
27
|
+
class SmartEnum
|
28
|
+
module Attributes
|
29
|
+
Boolean = [TrueClass, FalseClass].freeze
|
30
|
+
|
31
|
+
def self.included(base)
|
32
|
+
base.extend(ClassMethods)
|
33
|
+
end
|
34
|
+
|
35
|
+
module ClassMethods
|
36
|
+
def attribute_set
|
37
|
+
@attribute_set ||= {}
|
38
|
+
end
|
39
|
+
|
40
|
+
def inherited(child_class)
|
41
|
+
# STI children should start with an attribute set cloned from their parent.
|
42
|
+
# Otherwise theirs will start blank.
|
43
|
+
child_class.instance_variable_set(:@attribute_set, self.attribute_set.dup)
|
44
|
+
# STI children must *share* a reference to the same init_mutex as their
|
45
|
+
# parent so that reads are correctly blocked during async loading.
|
46
|
+
child_class.instance_variable_set(:@_init_mutex, @_init_mutex)
|
47
|
+
end
|
48
|
+
|
49
|
+
def attribute(name, types, coercer: nil, reader_method: nil)
|
50
|
+
name = name.to_sym
|
51
|
+
# ensure `types` is an array. From activesupport's Array#wrap.
|
52
|
+
types = if types.nil?
|
53
|
+
[]
|
54
|
+
elsif types.respond_to?(:to_ary)
|
55
|
+
types.to_ary || [types]
|
56
|
+
else
|
57
|
+
[types]
|
58
|
+
end
|
59
|
+
attribute_set[name] = Attribute.new(name, types, coercer)
|
60
|
+
define_method(reader_method || name) do
|
61
|
+
attributes[name]
|
62
|
+
end
|
63
|
+
if types == Boolean
|
64
|
+
alias_method "#{name}?".to_sym, name
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def inspect
|
69
|
+
lock_str = @enum_locked ? "LOCKED" : "UNLOCKED"
|
70
|
+
"#{self}(#{lock_str} #{attribute_set.values.map(&:inspect).join(", ")})"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def attributes
|
75
|
+
@attributes ||= {}
|
76
|
+
end
|
77
|
+
|
78
|
+
def initialize(opts={})
|
79
|
+
if block_given?
|
80
|
+
fail "Block passed, but it would be ignored"
|
81
|
+
end
|
82
|
+
init_opts = opts.symbolize_keys
|
83
|
+
if self.class.attribute_set.empty?
|
84
|
+
fail "no attributes defined for #{self.class}"
|
85
|
+
end
|
86
|
+
self.class.attribute_set.each do |attr_name, attr_def|
|
87
|
+
if (arg=init_opts.delete(attr_name))
|
88
|
+
if attr_def.types.any?{|type| arg.is_a?(type) }
|
89
|
+
# No coercion necessary
|
90
|
+
attributes[attr_name] = arg
|
91
|
+
elsif attr_def.coercer
|
92
|
+
coerced_arg = attr_def.coercer.call(arg)
|
93
|
+
if attr_def.types.none?{|type| coerced_arg.is_a?(type) }
|
94
|
+
# Coercer didn't give correct type
|
95
|
+
fail "coercer for #{attr_name} failed to coerce #{arg} to one of #{attr_def.types.inspect}. Got #{coerced_arg}:#{coerced_arg.class} instead"
|
96
|
+
end
|
97
|
+
# Coercer worked
|
98
|
+
attributes[attr_name] = coerced_arg
|
99
|
+
else
|
100
|
+
# Wrong type, no coercer passed
|
101
|
+
fail "Attribute :#{attr_name} passed #{arg}:#{arg.class} in initializer, but needs #{attr_def.types.inspect} and has no coercer"
|
102
|
+
end
|
103
|
+
else
|
104
|
+
if attr_def.types == Boolean
|
105
|
+
# booleans should always be true or false, not nil
|
106
|
+
attributes[attr_name] = false
|
107
|
+
else
|
108
|
+
# Nothing provided for this attr in init opts, set to nil
|
109
|
+
# to make sure we always have a complete attributes hash.
|
110
|
+
attributes[attr_name] = nil
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
if init_opts.any?
|
115
|
+
fail "unrecognized options: #{init_opts.inspect}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def inspect
|
120
|
+
"#<#{self.class} #{attributes.map{|k,v| "#{k}: #{v.inspect}"}.join(", ")}>"
|
121
|
+
end
|
122
|
+
|
123
|
+
def freeze_attributes
|
124
|
+
attributes.values.each(&:freeze)
|
125
|
+
attributes.freeze
|
126
|
+
self
|
127
|
+
end
|
128
|
+
|
129
|
+
class Attribute
|
130
|
+
attr_reader :name, :types, :coercer
|
131
|
+
|
132
|
+
def initialize(name, types, coercer)
|
133
|
+
@name = name
|
134
|
+
@types = types
|
135
|
+
@coercer = coercer
|
136
|
+
end
|
137
|
+
|
138
|
+
def inspect
|
139
|
+
type_str = types.length > 1 ? types.join("|") : types[0]
|
140
|
+
"#{name}: #{type_str}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Simple emulation of the monetize macro.
|
4
|
+
class SmartEnum
|
5
|
+
INTEGER = [Integer].freeze
|
6
|
+
module MonetizeInterop
|
7
|
+
require 'money'
|
8
|
+
|
9
|
+
CENTS_SUFFIX = /_cents\z/
|
10
|
+
# Note: this ignores the currency column since we only ever monetize things
|
11
|
+
# as USD. If that changes this should start reading the currency column.
|
12
|
+
def monetize(cents_field_name, as: nil, **opts)
|
13
|
+
if opts.any?
|
14
|
+
fail "unsupported options: #{opts.keys.join(',')}"
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_def = attribute_set[cents_field_name.to_sym]
|
18
|
+
if !attr_def
|
19
|
+
fail "no attribute called #{cents_field_name}, (Do you need to add '_cents'?)"
|
20
|
+
end
|
21
|
+
|
22
|
+
if attr_def.types != INTEGER
|
23
|
+
fail "attribute #{cents_field_name.inspect} can't monetize, only Integer is allowed"
|
24
|
+
end
|
25
|
+
|
26
|
+
money_attribute = as || cents_field_name.to_s.sub(CENTS_SUFFIX, '')
|
27
|
+
|
28
|
+
define_method(money_attribute) do
|
29
|
+
if MonetizeInterop.memoize_method_value
|
30
|
+
@money_cache ||= {}
|
31
|
+
@money_cache[money_attribute] ||= Money.new(public_send(cents_field_name))
|
32
|
+
else
|
33
|
+
Money.new(public_send(cents_field_name))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
@memoize_method_value = true
|
39
|
+
|
40
|
+
def self.memoize_method_value
|
41
|
+
@memoize_method_value
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.disable_memoization!
|
45
|
+
@memoize_method_value = false
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
# Methods for registering values from YAML files
|
5
|
+
class SmartEnum
|
6
|
+
module YamlStore
|
7
|
+
def register_values_from_file!
|
8
|
+
unless SmartEnum::YamlStore.data_root
|
9
|
+
raise "Must set SmartEnum::YamlStore.data_root before using `register_values_from_file!`"
|
10
|
+
end
|
11
|
+
unless self.name
|
12
|
+
raise "Cannot infer data file for anonymous class"
|
13
|
+
end
|
14
|
+
|
15
|
+
filename = "#{self.name.tableize}.yml"
|
16
|
+
file_path = File.join(SmartEnum::YamlStore.data_root, filename)
|
17
|
+
values = YAML.load_file(file_path)
|
18
|
+
register_values(values, self, detect_sti_types: true)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.data_root
|
22
|
+
@data_root
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.data_root=(val)
|
26
|
+
@data_root = val
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# automatically enable YAML store when this file is loaded
|
31
|
+
extend YamlStore
|
32
|
+
end
|
data/lib/smart_enum.rb
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "smart_enum/version"
|
4
|
+
require "smart_enum/associations"
|
5
|
+
require "smart_enum/attributes"
|
6
|
+
|
7
|
+
# A class used to build in-memory graphs of "lookup" objects that are
|
8
|
+
# long-lived and can associate among themselves or ActiveRecord instances.
|
9
|
+
#
|
10
|
+
# Example:
|
11
|
+
#
|
12
|
+
# class Foo < SmartEnum
|
13
|
+
# attribute :id, Integer
|
14
|
+
# has_many :accounts, :class_name => "Customer"
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# class Bar < SmartEnum
|
18
|
+
# attribute :foo_id, Integer
|
19
|
+
# belongs_to :foo
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# Foo.register_values([{id: 1},{id: 2}])
|
23
|
+
# Bar.register_values([{id:9, foo_id: 1},{id: 10, foo_id: 2}])
|
24
|
+
# Bar.find(1)
|
25
|
+
# # ActiveRecord::RecordNotFound: Couldn't find Bar with 'id'=1
|
26
|
+
# bar = Bar.find(9)
|
27
|
+
# # => #<Bar:0x007fcb6440a1f0 @attributes={:foo_id=>1, :id=>9}>
|
28
|
+
# bar.foo
|
29
|
+
# # => #<Foo:0x007fcb643633c8 @attributes={:id=>1}>
|
30
|
+
# bar.foo.accounts
|
31
|
+
# # Customer Load (1.3ms) SELECT "customers".* FROM "customers" WHERE "customers"."foo_id" = 1
|
32
|
+
# # => [#<Customer id: 13, foo_id: 1>, ...]
|
33
|
+
#
|
34
|
+
class SmartEnum
|
35
|
+
include SmartEnum::Attributes
|
36
|
+
|
37
|
+
def self.[](id)
|
38
|
+
ensure_ready_for_reads!
|
39
|
+
_enum_storage[id]
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.values
|
43
|
+
ensure_ready_for_reads!
|
44
|
+
_enum_storage.values
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.enum_locked?
|
48
|
+
@enum_locked
|
49
|
+
end
|
50
|
+
|
51
|
+
class << self
|
52
|
+
attr_accessor :abstract_class
|
53
|
+
|
54
|
+
protected def _enum_storage
|
55
|
+
@_enum_storage ||= {}
|
56
|
+
end
|
57
|
+
|
58
|
+
protected def ensure_ready_for_reads!
|
59
|
+
return true if enum_locked?
|
60
|
+
# This method must be called on a base class if in an STI heirarachy,
|
61
|
+
# because that is the only place deferred hashes are stored.
|
62
|
+
if superclass != SmartEnum
|
63
|
+
return superclass.ensure_ready_for_reads!
|
64
|
+
end
|
65
|
+
if @_deferred_values_present
|
66
|
+
# if we have deferred hashes, instantiate them and lock the enum
|
67
|
+
process_deferred_attr_hashes
|
68
|
+
lock_enum!
|
69
|
+
else
|
70
|
+
# No instance registration has been attempted, need to call
|
71
|
+
# register_values or register_value and lock_enum! first.
|
72
|
+
raise "Cannot use unlocked enum"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private def _constantize_cache
|
77
|
+
@_constantize_cache ||= {}
|
78
|
+
end
|
79
|
+
|
80
|
+
private def _descends_from_cache
|
81
|
+
@_descends_from_cache ||= {}
|
82
|
+
end
|
83
|
+
|
84
|
+
# The descendants of a class. From activesupport's Class#descendants
|
85
|
+
private def class_descendants(klass)
|
86
|
+
descendants = []
|
87
|
+
ObjectSpace.each_object(klass.singleton_class) do |k|
|
88
|
+
next if k.singleton_class?
|
89
|
+
descendants.unshift k unless k == self
|
90
|
+
end
|
91
|
+
descendants
|
92
|
+
end
|
93
|
+
|
94
|
+
private def _deferred_attr_hashes
|
95
|
+
@_deferred_attr_hashes ||= []
|
96
|
+
end
|
97
|
+
|
98
|
+
private def process_deferred_attr_hashes
|
99
|
+
_deferred_attr_hashes.each do |args|
|
100
|
+
register_value(**args)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
extend Associations
|
106
|
+
|
107
|
+
def self.lock_enum!
|
108
|
+
return if @enum_locked
|
109
|
+
@enum_locked = true
|
110
|
+
@_constantize_cache = nil
|
111
|
+
@_descends_from_cache = nil
|
112
|
+
|
113
|
+
_enum_storage.freeze
|
114
|
+
class_descendants(self).each do |klass|
|
115
|
+
klass.lock_enum!
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.register_values(values, enum_type=self, detect_sti_types: false)
|
120
|
+
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
|
+
end
|
123
|
+
@_deferred_values_present = true
|
124
|
+
end
|
125
|
+
|
126
|
+
# TODO: allow a SmartEnum to define its own type discriminator attr?
|
127
|
+
DEFAULT_TYPE_ATTR_STR = "type"
|
128
|
+
DEFAULT_TYPE_ATTR_SYM = :type
|
129
|
+
|
130
|
+
def self.register_value(enum_type: self, detect_sti_types: false, **attrs)
|
131
|
+
fail EnumLocked.new(enum_type) if enum_locked?
|
132
|
+
type_attr_val = attrs[DEFAULT_TYPE_ATTR_STR] || attrs[DEFAULT_TYPE_ATTR_SYM]
|
133
|
+
klass = if type_attr_val && detect_sti_types
|
134
|
+
_constantize_cache[type_attr_val] ||= type_attr_val.constantize
|
135
|
+
else
|
136
|
+
enum_type
|
137
|
+
end
|
138
|
+
unless (_descends_from_cache[klass] ||= (klass <= self))
|
139
|
+
raise "Specified class #{klass} must derive from #{self}"
|
140
|
+
end
|
141
|
+
if klass.abstract_class
|
142
|
+
raise "#{klass} is marked as abstract and may not be registered"
|
143
|
+
end
|
144
|
+
|
145
|
+
instance = klass.new(attrs)
|
146
|
+
id = instance.id
|
147
|
+
raise "Must provide id" unless id
|
148
|
+
raise "Already registered id #{id}!" if _enum_storage.has_key?(id)
|
149
|
+
instance.freeze_attributes
|
150
|
+
_enum_storage[id] = instance
|
151
|
+
if klass != self
|
152
|
+
klass._enum_storage[id] = instance
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
class EnumLocked < StandardError
|
157
|
+
def initialize(klass)
|
158
|
+
super("#{klass} has been locked and can not be written to")
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
data/smart_enum.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('../lib', __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'smart_enum/version'
|
6
|
+
|
7
|
+
|
8
|
+
Gem::Specification.new do |spec|
|
9
|
+
spec.name = "smart_enum"
|
10
|
+
spec.version = SmartEnum::VERSION
|
11
|
+
spec.authors = ["Carl Brasic", "Joshua Flanagan"]
|
12
|
+
spec.email = ["cbrasic@gmail.com", "joshuaflanagan@gmail.com"]
|
13
|
+
|
14
|
+
spec.summary = %q{Enums to replace database lookup tables}
|
15
|
+
spec.description = %q{Enums to replace database lookup tables}
|
16
|
+
spec.homepage = "https://github.com/ShippingEasy/smart_enum"
|
17
|
+
spec.license = "MIT"
|
18
|
+
|
19
|
+
spec.files = Dir["lib/**/*", "Rakefile", "README.md", "LICENSE.txt", "smart_enum.gemspec"]
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
|
25
|
+
# needed to run test suite for optional features, but consumers don't need it
|
26
|
+
spec.add_development_dependency "activerecord"
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: smart_enum
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Carl Brasic
|
8
|
+
- Joshua Flanagan
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2018-07-09 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activerecord
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '0'
|
21
|
+
type: :development
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '0'
|
28
|
+
description: Enums to replace database lookup tables
|
29
|
+
email:
|
30
|
+
- cbrasic@gmail.com
|
31
|
+
- joshuaflanagan@gmail.com
|
32
|
+
executables: []
|
33
|
+
extensions: []
|
34
|
+
extra_rdoc_files: []
|
35
|
+
files:
|
36
|
+
- LICENSE.txt
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- lib/smart_enum.rb
|
40
|
+
- lib/smart_enum/active_record_compatibility.rb
|
41
|
+
- lib/smart_enum/associations.rb
|
42
|
+
- lib/smart_enum/attributes.rb
|
43
|
+
- lib/smart_enum/monetize_interop.rb
|
44
|
+
- lib/smart_enum/version.rb
|
45
|
+
- lib/smart_enum/yaml_store.rb
|
46
|
+
- smart_enum.gemspec
|
47
|
+
homepage: https://github.com/ShippingEasy/smart_enum
|
48
|
+
licenses:
|
49
|
+
- MIT
|
50
|
+
metadata: {}
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options: []
|
53
|
+
require_paths:
|
54
|
+
- lib
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '0'
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '0'
|
65
|
+
requirements: []
|
66
|
+
rubyforge_project:
|
67
|
+
rubygems_version: 2.7.4
|
68
|
+
signing_key:
|
69
|
+
specification_version: 4
|
70
|
+
summary: Enums to replace database lookup tables
|
71
|
+
test_files: []
|