smart_enum 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []