enum_table 0.0.1
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.
- data/.gitignore +2 -0
- data/CHANGELOG +3 -0
- data/Gemfile +2 -0
- data/LICENSE +20 -0
- data/README.markdown +136 -0
- data/Rakefile +1 -0
- data/enum_table.gemspec +19 -0
- data/lib/enum_table.rb +11 -0
- data/lib/enum_table/railtie.rb +13 -0
- data/lib/enum_table/record.rb +214 -0
- data/lib/enum_table/reflection.rb +52 -0
- data/lib/enum_table/schema_dumper.rb +51 -0
- data/lib/enum_table/schema_statements.rb +86 -0
- data/lib/enum_table/version.rb +11 -0
- data/test/database.yml +19 -0
- data/test/enum_table/test_record.rb +578 -0
- data/test/enum_table/test_reflection.rb +114 -0
- data/test/enum_table/test_schema_dumper.rb +75 -0
- data/test/enum_table/test_schema_statements.rb +131 -0
- data/test/test_helper.rb +53 -0
- metadata +109 -0
data/.gitignore
ADDED
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) George Ogata
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
## Enum Table
|
2
|
+
|
3
|
+
Table-based enumerations for ActiveRecord.
|
4
|
+
|
5
|
+
## What?
|
6
|
+
|
7
|
+
When you have a column that should only take one of a finite set of string
|
8
|
+
values (e.g., gender, statuses through some workflow), it's usually best not to
|
9
|
+
store these as strings. Many databases, such as MySQL and PostgreSQL have native
|
10
|
+
enum types which effectively let you treat these as strings while storing them
|
11
|
+
internally using as few bytes as possible. Indeed there are already
|
12
|
+
[plugins][enum_column3] that let you use these native enum types.
|
13
|
+
|
14
|
+
But sometimes this is inadequate.
|
15
|
+
|
16
|
+
Most obviously, not all databases have a native enum type, notably SQLite.
|
17
|
+
|
18
|
+
Further, in the case of MySQL, the enum type leaves a lot to be desired in a
|
19
|
+
production setting. If you need to do anything to the list of allowed values
|
20
|
+
other than adding values to the end of the list, MySQL will rebuild the entire
|
21
|
+
table, which can be very slow. Unless you're using something like
|
22
|
+
[pt-online-schema-change][pt-osc], it will also lock the table during this
|
23
|
+
period, which could be unacceptable for large tables.
|
24
|
+
|
25
|
+
A common alternative is to simply keep the value-to-id mapping in the
|
26
|
+
application, hardcoded in the model. The downside of this is that your database
|
27
|
+
is no longer self-documenting: the integers mean nothing without the value
|
28
|
+
mapping buried in your application code, making it hard to work with the
|
29
|
+
database directly. Another problem is the database cannot enforce any
|
30
|
+
[referential integrity][foreigner].
|
31
|
+
|
32
|
+
This plugin implements a different strategy which solves the above problems -
|
33
|
+
each enum is defined by a table with `id` and `value` columns, which defines the
|
34
|
+
values and the integers they map to. Altering the values can be done with simple
|
35
|
+
DDL statements, which do not require rebuilding any tables.
|
36
|
+
|
37
|
+
[enum_column3]: https://github.com/taktsoft/enum_column3
|
38
|
+
[pt-osc]: http://www.percona.com/doc/percona-toolkit/2.1/pt-online-schema-change.html
|
39
|
+
[foreigner]: https://github.com/matthuhiggins/foreigner
|
40
|
+
|
41
|
+
## Usage
|
42
|
+
|
43
|
+
Create your enum tables in migrations. Example:
|
44
|
+
|
45
|
+
create_enum_table :user_genders do |t|
|
46
|
+
t.add :male
|
47
|
+
t.add :female
|
48
|
+
end
|
49
|
+
|
50
|
+
Then add the enum ID to your model table:
|
51
|
+
|
52
|
+
add_column :users, :gender_id, null: false
|
53
|
+
|
54
|
+
Then in your model:
|
55
|
+
|
56
|
+
class User < ActiveRecord::Base
|
57
|
+
enum :gender
|
58
|
+
end
|
59
|
+
|
60
|
+
Note the convention: for a model `User` with enum `gender`, the column is
|
61
|
+
`users.gender_id`, and the enum_table is `user_genders`. You can override these
|
62
|
+
with the `:id_name` and `:table` options:
|
63
|
+
|
64
|
+
enum :gender, id_name: :sex_id, table: :sexes
|
65
|
+
|
66
|
+
### Custom columns
|
67
|
+
|
68
|
+
While the names `id` and `value` are fixed, you can change other attributes of
|
69
|
+
the column. For example, the ID has `limit: 1` by default, but you can change
|
70
|
+
this if you have a large list of enum values:
|
71
|
+
|
72
|
+
create_enum_table :user_countries do |t|
|
73
|
+
t.id limit: 2
|
74
|
+
t.add 'Afghanistan'
|
75
|
+
t.add 'Albania'
|
76
|
+
# ...
|
77
|
+
end
|
78
|
+
|
79
|
+
Similarly you can customize the `value` column, say if you want to place a
|
80
|
+
varchar limit:
|
81
|
+
|
82
|
+
create_enum_table :user_countries do |t|
|
83
|
+
t.value limit: 100
|
84
|
+
# ...
|
85
|
+
end
|
86
|
+
|
87
|
+
### Updating enums
|
88
|
+
|
89
|
+
To change the list of enums:
|
90
|
+
|
91
|
+
change_enum_table :user_genders do |t|
|
92
|
+
t.add :other
|
93
|
+
t.remove :male
|
94
|
+
end
|
95
|
+
|
96
|
+
To drop an enum table:
|
97
|
+
|
98
|
+
drop_enum_table :user_genders
|
99
|
+
|
100
|
+
Under the hood, `create_enum_table` and `drop_enum_table` maintain the list of
|
101
|
+
enum tables in the `enum_tables` table. This allows the table data to be tracked
|
102
|
+
by `db/schema.rb` so it gets copied to your test database.
|
103
|
+
|
104
|
+
### Hardcoded mappings
|
105
|
+
|
106
|
+
If you really want, you can forego the table completely and just hardcode the
|
107
|
+
ids and values in your model:
|
108
|
+
|
109
|
+
enum :genders, table: {male: 1, female: 2}
|
110
|
+
|
111
|
+
Or since our IDs are 1-based and sequential:
|
112
|
+
|
113
|
+
enum :genders, table: [:male, :female]
|
114
|
+
|
115
|
+
Of course, by not using tables, you lose some of the advantages mentioned
|
116
|
+
earlier, namely a self-documenting database and referential integrity.
|
117
|
+
|
118
|
+
### Values
|
119
|
+
|
120
|
+
By default, `user.gender` will be either the symbol `:male` or `:female`. If
|
121
|
+
you're transitioning from using old-fashioned `varchar`s, however, you may find
|
122
|
+
it less disruptive to use strings instead. Do that with the `:type` option:
|
123
|
+
|
124
|
+
enum :genders, type: :string
|
125
|
+
|
126
|
+
## Contributing
|
127
|
+
|
128
|
+
* [Bug reports](https://github.com/howaboutwe/enum_table/issues)
|
129
|
+
* [Source](https://github.com/howaboutwe/enum_table)
|
130
|
+
* Patches: Fork on Github, send pull request.
|
131
|
+
* Include tests where practical.
|
132
|
+
* Leave the version alone, or bump it in a separate commit.
|
133
|
+
|
134
|
+
## Copyright
|
135
|
+
|
136
|
+
Copyright (c) HowAboutWe. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'ritual'
|
data/enum_table.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.unshift File.expand_path('lib', File.dirname(__FILE__))
|
3
|
+
require 'enum_table/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |gem|
|
6
|
+
gem.name = 'enum_table'
|
7
|
+
gem.version = EnumTable::VERSION
|
8
|
+
gem.authors = ['George Ogata']
|
9
|
+
gem.email = ['george.ogata@gmail.com']
|
10
|
+
gem.summary = "Enumeration tables for ActiveRecord"
|
11
|
+
gem.homepage = 'http://github.com/howaboutwe/enum_table'
|
12
|
+
|
13
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
14
|
+
gem.files = `git ls-files`.split("\n")
|
15
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
16
|
+
|
17
|
+
gem.add_runtime_dependency 'activerecord', '~> 3.2.0'
|
18
|
+
gem.add_development_dependency 'ritual', '~> 0.4.1'
|
19
|
+
end
|
data/lib/enum_table.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module EnumTable
|
2
|
+
autoload :VERSION, 'enum_table/version'
|
3
|
+
autoload :Record, 'enum_table/record'
|
4
|
+
autoload :Reflection, 'enum_table/reflection'
|
5
|
+
autoload :SchemaDumper, 'enum_table/schema_dumper'
|
6
|
+
autoload :SchemaStatements, 'enum_table/schema_statements'
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'enum_table/railtie' if defined?(Rails)
|
10
|
+
ActiveRecord::Base.send :include, EnumTable::Record
|
11
|
+
ActiveRecord::ConnectionAdapters::AbstractAdapter.send :include, EnumTable::SchemaStatements
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module EnumTable
|
2
|
+
class Railtie < Rails::Railtie
|
3
|
+
rake_tasks do
|
4
|
+
namespace :enum_table do
|
5
|
+
task :load_schema_dumper do
|
6
|
+
require 'enum_table/schema_dumper'
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
Rake::Task['db:schema:dump'].prerequisites << 'enum_table:load_schema_dumper'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
module EnumTable
|
2
|
+
module Record
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
class_attribute :enums
|
7
|
+
self.enums = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def enum(name, options={})
|
12
|
+
name = name.to_sym
|
13
|
+
reflection = enums[name] ? enums[name].dup : Reflection.new(name)
|
14
|
+
[:type, :id_name].each do |key|
|
15
|
+
value = options[key] and
|
16
|
+
reflection.send "#{key}=", value
|
17
|
+
end
|
18
|
+
enum_map(name, options).each do |value, id|
|
19
|
+
reflection.add_value id, value
|
20
|
+
end
|
21
|
+
self.enums = enums.merge(name => reflection, name.to_s => reflection)
|
22
|
+
|
23
|
+
class_eval <<-EOS
|
24
|
+
def #{name}
|
25
|
+
read_enum(:#{name})
|
26
|
+
end
|
27
|
+
|
28
|
+
def #{name}=(value)
|
29
|
+
write_enum(:#{name}, value)
|
30
|
+
end
|
31
|
+
|
32
|
+
def #{name}?
|
33
|
+
query_enum(:#{name})
|
34
|
+
end
|
35
|
+
|
36
|
+
def #{name}_changed?
|
37
|
+
enum_changed?(:#{name})
|
38
|
+
end
|
39
|
+
|
40
|
+
def #{name}_was
|
41
|
+
enum_was(:#{name})
|
42
|
+
end
|
43
|
+
|
44
|
+
def #{name}_change
|
45
|
+
enum_change(:#{name})
|
46
|
+
end
|
47
|
+
EOS
|
48
|
+
|
49
|
+
reflection
|
50
|
+
end
|
51
|
+
|
52
|
+
def enum_map(name, options)
|
53
|
+
case (table = options[:table])
|
54
|
+
when Hash
|
55
|
+
table
|
56
|
+
when Array
|
57
|
+
map = {}
|
58
|
+
table.each_with_index { |element, i| map[element] = i + 1 }
|
59
|
+
map
|
60
|
+
when String, Symbol, nil
|
61
|
+
map = {}
|
62
|
+
table_name = table || "#{self.table_name.singularize}_#{name.to_s.pluralize}"
|
63
|
+
connection.execute("SELECT id, value FROM #{table_name}").each do |row|
|
64
|
+
map[row[1]] = row[0]
|
65
|
+
end
|
66
|
+
map
|
67
|
+
else
|
68
|
+
raise ArgumentError, "invalid table specifier: #{table.inspect}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def reflect_on_enum(name)
|
73
|
+
enums[name]
|
74
|
+
end
|
75
|
+
|
76
|
+
def enum_id(name, value)
|
77
|
+
reflection = enums[name] or
|
78
|
+
raise ArgumentError, "no such enum: #{name}"
|
79
|
+
reflection.id(value)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Enables enums for STI types.
|
83
|
+
def builtin_inheritance_column # :nodoc:
|
84
|
+
# Can this be made less brittle?
|
85
|
+
if self == ActiveRecord::Base
|
86
|
+
'type'
|
87
|
+
else
|
88
|
+
(@builtin_inheritance_column ||= nil) || superclass.builtin_inheritance_column
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def inheritance_enum # :nodoc:
|
93
|
+
@inheritance_enum ||= enums[builtin_inheritance_column.to_sym]
|
94
|
+
end
|
95
|
+
|
96
|
+
def inheritance_column # :nodoc:
|
97
|
+
(reflection = inheritance_enum) ? reflection.id_name.to_s : super
|
98
|
+
end
|
99
|
+
|
100
|
+
def sti_name # :nodoc:
|
101
|
+
(reflection = inheritance_enum) ? reflection.id(super) : super
|
102
|
+
end
|
103
|
+
|
104
|
+
def find_sti_class(type_name) # :nodoc:
|
105
|
+
(reflection = inheritance_enum) ? super(reflection.value(type_name).to_s) : super
|
106
|
+
end
|
107
|
+
|
108
|
+
# Enables .find_by_name(value) for enums.
|
109
|
+
def expand_hash_conditions_for_aggregates(attrs) # :nodoc:
|
110
|
+
conditions = super
|
111
|
+
enums.each do |name, reflection|
|
112
|
+
if conditions.key?(name)
|
113
|
+
value = conditions.delete(name)
|
114
|
+
elsif conditions.key?((string_name = name.to_s))
|
115
|
+
value = conditions.delete(string_name)
|
116
|
+
else
|
117
|
+
next
|
118
|
+
end
|
119
|
+
if value.is_a?(Array)
|
120
|
+
id = value.map { |el| reflection.id(el) }
|
121
|
+
else
|
122
|
+
id = reflection.id(value)
|
123
|
+
end
|
124
|
+
conditions[reflection.id_name] = id
|
125
|
+
end
|
126
|
+
conditions
|
127
|
+
end
|
128
|
+
|
129
|
+
# Enables .where(name: value) for enums.
|
130
|
+
def expand_attribute_names_for_aggregates(attribute_names) # :nodoc:
|
131
|
+
attribute_names = super
|
132
|
+
enums.each do |name, reflection|
|
133
|
+
index = attribute_names.index(name) and
|
134
|
+
attribute_names[index] = reflection.id_name
|
135
|
+
end
|
136
|
+
attribute_names
|
137
|
+
end
|
138
|
+
|
139
|
+
# Enables state_machine to set initial values for states. Ick.
|
140
|
+
def initialize_attributes(attributes) # :nodoc:
|
141
|
+
attributes = super
|
142
|
+
enums.each do |name, reflection|
|
143
|
+
if (value = attributes.delete(reflection.name.to_s))
|
144
|
+
attributes[reflection.id_name.to_s] ||= reflection.id(value)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
attributes
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def enum(name)
|
152
|
+
self.class.enums[name]
|
153
|
+
end
|
154
|
+
|
155
|
+
def enum!(name)
|
156
|
+
self.class.enums[name] or
|
157
|
+
raise ArgumentError, "no such enum: #{name}"
|
158
|
+
end
|
159
|
+
|
160
|
+
def enum_id(name, value)
|
161
|
+
self.class.enum_id(name, value)
|
162
|
+
end
|
163
|
+
|
164
|
+
def read_enum(name)
|
165
|
+
reflection = enum!(name)
|
166
|
+
id = read_attribute(reflection.id_name)
|
167
|
+
reflection.value(id)
|
168
|
+
end
|
169
|
+
|
170
|
+
def query_enum(name)
|
171
|
+
reflection = enum!(name)
|
172
|
+
id = read_attribute(reflection.id_name)
|
173
|
+
!!reflection.value(id)
|
174
|
+
end
|
175
|
+
|
176
|
+
def write_enum(name, value)
|
177
|
+
reflection = enum!(name)
|
178
|
+
id = reflection.id(value)
|
179
|
+
write_attribute(reflection.id_name, id)
|
180
|
+
value
|
181
|
+
end
|
182
|
+
|
183
|
+
def enum_changed?(name)
|
184
|
+
reflection = enum!(name)
|
185
|
+
attribute_changed?(reflection.id_name.to_s)
|
186
|
+
end
|
187
|
+
|
188
|
+
def enum_was(name)
|
189
|
+
reflection = enum!(name)
|
190
|
+
id = attribute_was(reflection.id_name.to_s)
|
191
|
+
reflection.value(id)
|
192
|
+
end
|
193
|
+
|
194
|
+
def enum_change(name)
|
195
|
+
reflection = enum!(name)
|
196
|
+
change = attribute_change(reflection.id_name.to_s) or
|
197
|
+
return nil
|
198
|
+
old_id, new_id = *change
|
199
|
+
[reflection.value(old_id), reflection.value(new_id)]
|
200
|
+
end
|
201
|
+
|
202
|
+
def read_attribute(name)
|
203
|
+
reflection = enum(name) or
|
204
|
+
return super
|
205
|
+
read_enum(name)
|
206
|
+
end
|
207
|
+
|
208
|
+
def write_attribute(name, value)
|
209
|
+
reflection = enum(name) or
|
210
|
+
return super
|
211
|
+
write_enum(name, value)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module EnumTable
|
2
|
+
class Reflection
|
3
|
+
def initialize(name, options={})
|
4
|
+
@name = name
|
5
|
+
@id_name = options[:id_name] || :"#{name}_id"
|
6
|
+
@type = options[:type] || :symbol
|
7
|
+
@type == :string || @type == :symbol or
|
8
|
+
raise ArgumentError, "invalid type: #{type.inspect}"
|
9
|
+
|
10
|
+
@strings_to_ids = {}
|
11
|
+
@values_to_ids = {}
|
12
|
+
@ids_to_values = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize_copy(other)
|
16
|
+
@name = other.name
|
17
|
+
@id_name = other.id_name
|
18
|
+
@type = other.type
|
19
|
+
|
20
|
+
@strings_to_ids = other.instance_variable_get(:@strings_to_ids).dup
|
21
|
+
@values_to_ids = other.instance_variable_get(:@values_to_ids).dup
|
22
|
+
@ids_to_values = other.instance_variable_get(:@ids_to_values).dup
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :name
|
26
|
+
attr_accessor :id_name, :type
|
27
|
+
|
28
|
+
def add_value(id, value)
|
29
|
+
@strings_to_ids[value.to_s] = id
|
30
|
+
|
31
|
+
cast_value = @type == :string ? value.to_s : value.to_sym
|
32
|
+
@values_to_ids[cast_value] = id
|
33
|
+
@ids_to_values[id] = cast_value
|
34
|
+
end
|
35
|
+
|
36
|
+
def id(value)
|
37
|
+
if value.is_a?(String) || type == :string
|
38
|
+
@strings_to_ids[value.to_s.strip]
|
39
|
+
else
|
40
|
+
@values_to_ids[value]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def value(id)
|
45
|
+
@ids_to_values[id]
|
46
|
+
end
|
47
|
+
|
48
|
+
def values
|
49
|
+
@values_to_ids.keys
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|