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