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 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task :default => :spec
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmartEnum
4
+ VERSION = "0.1.0"
5
+ 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
@@ -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: []