eav_hashes 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +136 -0
  3. data/Rakefile +30 -0
  4. data/init.rb +1 -0
  5. data/lib/eav_hashes.rb +8 -0
  6. data/lib/eav_hashes/activerecord_extension.rb +37 -0
  7. data/lib/eav_hashes/eav_entry.rb +129 -0
  8. data/lib/eav_hashes/eav_hash.rb +168 -0
  9. data/lib/eav_hashes/util.rb +122 -0
  10. data/lib/eav_hashes/version.rb +5 -0
  11. data/lib/generators/eav_migration/USAGE +26 -0
  12. data/lib/generators/eav_migration/eav_migration.rb +36 -0
  13. data/lib/generators/eav_migration/templates/eav_migration.erb +16 -0
  14. data/lib/tasks/eav_hashes_tasks.rake +4 -0
  15. data/spec/dummy/README.rdoc +261 -0
  16. data/spec/dummy/Rakefile +7 -0
  17. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  18. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  19. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  20. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  21. data/spec/dummy/app/models/custom_test_object.rb +7 -0
  22. data/spec/dummy/app/models/product.rb +4 -0
  23. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  24. data/spec/dummy/config.ru +4 -0
  25. data/spec/dummy/config/application.rb +68 -0
  26. data/spec/dummy/config/boot.rb +10 -0
  27. data/spec/dummy/config/database.yml +25 -0
  28. data/spec/dummy/config/environment.rb +5 -0
  29. data/spec/dummy/config/environments/development.rb +37 -0
  30. data/spec/dummy/config/environments/production.rb +67 -0
  31. data/spec/dummy/config/environments/test.rb +37 -0
  32. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  33. data/spec/dummy/config/initializers/inflections.rb +15 -0
  34. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  35. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  36. data/spec/dummy/config/initializers/session_store.rb +8 -0
  37. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  38. data/spec/dummy/config/locales/en.yml +5 -0
  39. data/spec/dummy/config/routes.rb +58 -0
  40. data/spec/dummy/db/development.sqlite3 +0 -0
  41. data/spec/dummy/db/migrate/20121206133059_create_products.rb +9 -0
  42. data/spec/dummy/db/migrate/20121210055854_create_product_tech_specs.rb +16 -0
  43. data/spec/dummy/db/schema.rb +35 -0
  44. data/spec/dummy/db/seeds.rb +31 -0
  45. data/spec/dummy/db/test.sqlite3 +0 -0
  46. data/spec/dummy/log/development.log +46 -0
  47. data/spec/dummy/log/test.log +3878 -0
  48. data/spec/dummy/public/404.html +26 -0
  49. data/spec/dummy/public/422.html +26 -0
  50. data/spec/dummy/public/500.html +25 -0
  51. data/spec/dummy/public/favicon.ico +0 -0
  52. data/spec/dummy/script/rails +6 -0
  53. data/spec/lib/eav_hashes/eav_hash_spec.rb +138 -0
  54. data/spec/lib/generators/eav_migration_spec.rb +61 -0
  55. data/spec/spec_helper.rb +25 -0
  56. metadata +178 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2012 Ilya Ostrovskiy
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.md ADDED
@@ -0,0 +1,136 @@
1
+ eav_hashes
2
+ =========
3
+
4
+ [![Code Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/200proof/eav_hashes) [![Build Status](https://travis-ci.org/200proof/eav_hashes.png?branch=master)](https://travis-ci.org/200proof/eav_hashes)
5
+
6
+ `eav_hashes` is a neato gem for implementing the EAV (entity-attribute-value)
7
+ database design pattern in your Rails models. All you need to do is add one
8
+ line to your model's code and that's it! Schema generation is automatically
9
+ handled for you.
10
+
11
+ Why would I need it?
12
+ -
13
+ Rails' ActiveRecord includes a helper function, `serialize`, to allow you to
14
+ save complex data types (like hashes) into your database. Unfortunately, it
15
+ isn't very useful. A lot of overhead is created from serialization and
16
+ deserialization, and you can't search by the contents of your hash. That's
17
+ where `eav_hashes` comes in.
18
+
19
+ How does it work?
20
+ -
21
+ Great question! Lets dive in with a simple code example:
22
+
23
+ ```ruby
24
+ class Product < ActiveRecord::Base
25
+ eav_hash_for :tech_specs
26
+ end
27
+ ```
28
+
29
+ Now run this generator to create a migration:
30
+
31
+ $ rails generate eav_migration Product tech_specs
32
+
33
+ And run the migration:
34
+
35
+ $ rake db:migrate
36
+
37
+ Now watch the magic the happen:
38
+
39
+ ```ruby
40
+ # Assuming this whole example is on a blank DB, of course
41
+ a_product = Product.new
42
+ a_product.tech_specs["Widget Power"] = "1.21 GW"
43
+ a_product.tech_specs["Battery Life (hours)"] = 12
44
+ a_product.tech_specs["Warranty (years)"] = 3.5
45
+ a_product.tech_specs["RoHS Compliant"] = true
46
+ a_product.save!
47
+
48
+ # Setting a value to nil deletes the entry
49
+ a_product.tech_specs["Warranty (years)"] = nil
50
+ a_product.save!
51
+
52
+ the_same_product = Product.first
53
+ puts the_same_product.tech_specs["nonexistant key"]
54
+
55
+ # magic alert: this actually gets the count of EVERY entry of every
56
+ # hash for this model, but for this example this works
57
+ puts "Entry Count: #{ProductTechSpecsEntry.count}"
58
+ the_same_product.tech_specs.each_pair do |key, value|
59
+ puts "#{key}: #{value.to_s}"
60
+ end
61
+
62
+ # Ruby's default types: Integer, Float, Complex, Rational, Symbol,
63
+ # TrueClass, and FalseClass are preserved between transactions like
64
+ # you would expect them to.
65
+ puts the_same_product.tech_specs["Battery Life (hours)"]+3
66
+ ```
67
+
68
+ And the output, as you can expect, will be along the lines of:
69
+
70
+ nil
71
+ Entry Count: 3
72
+ Widget Power: 1.21 GW
73
+ Battery Life (hours): 12
74
+ RoHS Compliant: true
75
+ 15
76
+
77
+
78
+ That looks incredibly simple, right? Good! It's supposed to be! All the magic
79
+ happens when you call `save!`.
80
+
81
+ Now you could start doing other cool stuff, like searching for products based
82
+ on their tech specs! You've already figured out how to do this, haven't you?
83
+
84
+ ```ruby
85
+ flux_capacitor = Product.find_by_tech_specs("Widget Power", "1.21 GW")
86
+ ```
87
+
88
+ Nifty, right?
89
+
90
+ Can I store arrays/hashes/custom types inside my hashes?
91
+ --
92
+ Sure, but they'll be serialized into YAML (so you cant search by them like you
93
+ would an eav_hash). The `value` column is a TEXT type by default but if you
94
+ want to optimize your DB size you could change it to a VARCHAR in the migration
95
+ if you don't plan on storing large values.
96
+
97
+
98
+ What if I want to change the table name?
99
+ --
100
+ By default, `eav_hash` uses a table name derived from the following:
101
+
102
+ ```ruby
103
+ "<ClassName>_<hash_name>".tableize
104
+ ```
105
+
106
+ You can change this by passing a symbol to the `:table_name` argument:
107
+
108
+ ```ruby
109
+ class Widget < ActiveRecord::Base
110
+ eav_hash_for :foobar, table_name: :bar_foo
111
+ end
112
+ ```
113
+
114
+ Just remember to edit the table name in the migration, or use the following
115
+ migration generator:
116
+
117
+ $ rails generate eav_migration Widget foobar bar_foo
118
+
119
+
120
+ What's the catch?
121
+ -
122
+ By using this software, you agree to write me into your will as your next of
123
+ kin, and to sacrifice the soul of your first born child to Beelzebub.
124
+
125
+ Just kidding, the code is released under the MIT license so you can use it for
126
+ whatever purposes you see fit. Just don't sue me if your application blows up
127
+ from the sheer awesomeness! Check out the LICENSE file for more information.
128
+
129
+ Special Thanks!
130
+ -
131
+ Thanks to Matt Kimmel (@mattkimmel) for adding support for models contained in namespaces.
132
+
133
+ I found a bug or want to contribute!
134
+ -
135
+ You're probably reading this from GitHub, so you know what to do. If not, the
136
+ Github project is at https://github.com/200proof/eav_hashes
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+
8
+ begin
9
+ require 'rdoc/task'
10
+ rescue LoadError
11
+ require 'rdoc/rdoc'
12
+ require 'rake/rdoctask'
13
+ RDoc::Task = Rake::RDocTask
14
+ end
15
+
16
+ RDoc::Task.new(:rdoc) do |rdoc|
17
+ rdoc.rdoc_dir = 'rdoc'
18
+ rdoc.title = 'EavHashes'
19
+ rdoc.options << '--line-numbers'
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+ Bundler::GemHelper.install_tasks
24
+
25
+ require "rspec/core"
26
+ require "rspec/core/rake_task"
27
+
28
+ RSpec::Core::RakeTask.new(:spec)
29
+
30
+ task :default => :spec
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'lib/eav_hashes'
data/lib/eav_hashes.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "eav_hashes/util"
2
+ require "eav_hashes/eav_entry"
3
+ require "eav_hashes/eav_hash"
4
+ require "eav_hashes/activerecord_extension"
5
+ require "generators/eav_migration/eav_migration"
6
+
7
+ # tally-ho!
8
+ ActiveRecord::Base.send :include, ActiveRecord::EavHashes
@@ -0,0 +1,37 @@
1
+ module ActiveRecord
2
+ module EavHashes
3
+ def self.included (base)
4
+ base.extend ActiveRecord::EavHashes::ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ def eav_hash_for (hash_name, options={})
9
+ # Fill in default options not otherwise specified
10
+ options[:hash_name] = hash_name
11
+ options[:parent_class_name] = self.name.to_sym
12
+ options = ActiveRecord::EavHashes::Util::fill_options_hash options
13
+
14
+ # Store the options hash in a class variable to create the EavHash object
15
+ # if and when it's actually used.
16
+ class_variable_set "@@#{hash_name}_hash_options".to_sym, options
17
+
18
+ # Create the association, the entry update hook, and a helper method to lazy-load the entries
19
+ class_eval <<-END_EVAL
20
+ has_many :#{options[:entry_assoc_name]}, class_name: #{options[:entry_class_name]}, foreign_key: "#{options[:parent_assoc_name]}_id"
21
+ after_save :save_#{hash_name}
22
+ def #{hash_name}
23
+ @#{hash_name} ||= ActiveRecord::EavHashes::EavHash.new(self, @@#{hash_name}_hash_options)
24
+ end
25
+
26
+ def save_#{hash_name}
27
+ @#{hash_name}.save_entries if @#{hash_name}
28
+ end
29
+
30
+ def self.find_by_#{hash_name} (key, value=nil)
31
+ self.find (ActiveRecord::EavHashes::Util::run_find_expression(key, value, @@#{hash_name}_hash_options))
32
+ end
33
+ END_EVAL
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,129 @@
1
+ module ActiveRecord
2
+ module EavHashes
3
+ # Used instead of nil when a nil value is assigned
4
+ # (otherwise, the value will try to deserialize itself and
5
+ # that would break everything horrifically)
6
+ class NilPlaceholder; end
7
+
8
+ # Represent an EAV row. This class should NOT be used directly, instead it should be inherited from
9
+ # by the class generated by eav_hash_for.
10
+ class EavEntry < ActiveRecord::Base
11
+ # prevent activerecord from thinking we're trying to do STI
12
+ self.abstract_class = true
13
+
14
+ # Tell ActiveRecord to convert the value to its DB storable format
15
+ before_save :serialize_value
16
+
17
+ # Let the key be assignable only once on creation
18
+ attr_readonly :entry_key
19
+
20
+ # Contains the values the value_type column should have based on the type of the value being stored
21
+ SUPPORTED_TYPES = {
22
+ :String => 0,
23
+ :Symbol => 1,
24
+ :Integer => 2,
25
+ :Fixnum => 2,
26
+ :Bignum => 2,
27
+ :Float => 3,
28
+ :Complex => 4,
29
+ :Rational => 5,
30
+ :Boolean => 6, # For code readability
31
+ :TrueClass => 6,
32
+ :FalseClass => 6,
33
+ :Object => 7 # anything else (including Hashes, Arrays) will be serialized to yaml and saved as Object
34
+ }
35
+
36
+ # Does some sanity checks.
37
+ def after_initialize
38
+ raise "key should be a string or symbol!" unless key.is_a? String or key.is_a? Symbol
39
+ raise "value should not be empty!" if @value.is_a? String and value.empty?
40
+ raise "value should not be nil!" if @value.nil?
41
+ end
42
+
43
+ # Gets the EAV row's value
44
+ def value
45
+ return nil if @value.is_a? NilPlaceholder
46
+ @value.nil? ? deserialize_value : @value
47
+ end
48
+
49
+ # Sets the EAV row's value
50
+ # @param [Object] val the value
51
+ def value= (val)
52
+ @value = (val.nil? ? NilPlaceholder.new : val)
53
+ end
54
+
55
+
56
+ def key
57
+ k = read_attribute :entry_key
58
+ (read_attribute :symbol_key) ? k.to_sym : k
59
+ end
60
+
61
+ # Raises an error if you try changing the key (unless no key is set)
62
+ def key= (val)
63
+ raise "Keys are immutable!" if read_attribute(:entry_key)
64
+ raise "Key must be a string!" unless val.is_a?(String) or val.is_a?(Symbol)
65
+ write_attribute :entry_key, val.to_s
66
+ write_attribute :symbol_key, (val.is_a? Symbol)
67
+ end
68
+
69
+ # Gets the value_type column's value for the type of value passed
70
+ # @param [Object] val the object whose value_type to determine
71
+ def self.get_value_type (val)
72
+ return nil if val.nil?
73
+ ret = SUPPORTED_TYPES[val.class.name.to_sym]
74
+ if ret.nil?
75
+ ret = SUPPORTED_TYPES[:Object]
76
+ end
77
+ ret
78
+ end
79
+
80
+ private
81
+ # Sets the value_type column to the appropriate value based on the value's type
82
+ def update_value_type
83
+ write_attribute :value_type, EavEntry.get_value_type(@value)
84
+ end
85
+
86
+ # Converts the value to its database-storable form and tells ActiveRecord that it's been changed (if it has)
87
+ def serialize_value
88
+ # Returning nil will prevent the row from being saved, to save some time since the EavHash that manages this
89
+ # entry will have marked it for deletion.
90
+ raise "Tried to save with a nil value!" if @value.nil? or @value.is_a? NilPlaceholder
91
+
92
+ update_value_type
93
+ if value_type == SUPPORTED_TYPES[:Object]
94
+ write_attribute :value, YAML::dump(@value)
95
+ else
96
+ write_attribute :value, @value.to_s
97
+ end
98
+
99
+ read_attribute :value
100
+ end
101
+
102
+ # Converts the value from it's database representation to the type specified in the value_type column.
103
+ def deserialize_value
104
+ if @value.nil?
105
+ @value = read_attribute :value
106
+ end
107
+
108
+ case value_type
109
+ when SUPPORTED_TYPES[:Object] # or Hash, Array, etc.
110
+ @value = YAML::load @value
111
+ when SUPPORTED_TYPES[:Symbol]
112
+ @value = @value.to_sym
113
+ when SUPPORTED_TYPES[:Integer] # or Fixnum, Bignum
114
+ @value = @value.to_i
115
+ when SUPPORTED_TYPES[:Float]
116
+ @value = @value.to_f
117
+ when SUPPORTED_TYPES[:Complex]
118
+ @value = Complex @value
119
+ when SUPPORTED_TYPES[:Rational]
120
+ @value = Rational @value
121
+ when SUPPORTED_TYPES[:Boolean]
122
+ @value = (@value == "true")
123
+ else
124
+ @value
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,168 @@
1
+ module ActiveRecord
2
+ module EavHashes
3
+ # Wraps a bunch of EavEntries and lets you use them like you would a hash
4
+ # This class should not be used directly and you should instead let eav_hash_for create one for you
5
+ class EavHash
6
+ # Creates a new EavHash. You should really let eav_hash_for do this for you...
7
+ # @param [ActiveRecord::Base] owner the Model which will own this hash
8
+ # @param [Hash] options the options hash which eav_hash generated
9
+ def initialize(owner, options)
10
+ Util::sanity_check options
11
+ @owner = owner
12
+ @options = options
13
+ end
14
+
15
+ # Saves any modified entries and deletes any which have been nil'd to save DB space
16
+ def save_entries
17
+ # The entries are lazy-loaded, so don't do anything if they haven't been accessed or modified
18
+ return unless (@entries and @changes_made)
19
+
20
+ @entries.values.each do |entry|
21
+ if entry.value.nil?
22
+ entry.delete
23
+ else
24
+ set_entry_owner(entry)
25
+ entry.save
26
+ end
27
+ end
28
+ end
29
+
30
+ # Gets the value of an EAV attribute
31
+ # @param [String, Symbol] key
32
+ def [](key)
33
+ raise "Key must be a string or a symbol!" unless key.is_a?(String) or key.is_a?(Symbol)
34
+ load_entries_if_needed
35
+ return @entries[key].value if @entries[key]
36
+ nil
37
+ end
38
+
39
+ # Sets the value of the EAV attribute `key` to `value`
40
+ # @param [String, Symbol] key the attribute
41
+ # @param [Object] value the value
42
+ def []=(key, value)
43
+ update_or_create_entry key, value
44
+ end
45
+
46
+ # I don't know why Ruby hashes don't have a shovel operator, but I will make damn sure that I
47
+ # fight the power and stick it to the man by implementing it.
48
+ # @param [Hash, EavHash] dirt the dirt to shovel (ba dum, tss)
49
+ def <<(dirt)
50
+ if dirt.is_a? Hash
51
+ dirt.each do |key, value|
52
+ update_or_create_entry key, value
53
+ end
54
+ elsif dirt.is_a? EavHash
55
+ dirt.entries.each do |key, entry|
56
+ update_or_create_entry key, entry.value
57
+ end
58
+ else
59
+ raise "You can't shovel something that's not a Hash or EavHash here!"
60
+ end
61
+
62
+ self
63
+ end
64
+
65
+ # Gets the raw hash containing EavEntries by their keys
66
+ def entries
67
+ load_entries_if_needed
68
+ end
69
+
70
+ # Gets the actual values this EavHash contains
71
+ def values
72
+ load_entries_if_needed
73
+
74
+ ret = []
75
+ @entries.values.each do |value|
76
+ ret << value
77
+ end
78
+
79
+ ret
80
+ end
81
+
82
+ # Gets the keys this EavHash manages
83
+ def keys
84
+ load_entries_if_needed
85
+ @entries.keys
86
+ end
87
+
88
+ # Emulates Hash.each
89
+ def each (&block)
90
+ as_hash.each block
91
+ end
92
+
93
+ # Emulates Hash.each_pair (same as each)
94
+ def each_pair (&block)
95
+ each &block
96
+ end
97
+
98
+ # Empties the hash by setting all the values to nil
99
+ # (without committing them, of course)
100
+ def clear
101
+ load_entries_if_needed
102
+ @entries.each do |_, entry|
103
+ entry.value = nil
104
+ end
105
+ end
106
+
107
+ # Returns a hash with each entry key mapped to its actual value,
108
+ # not the internal EavEntry
109
+ def as_hash
110
+ load_entries_if_needed
111
+ hsh = {}
112
+ @entries.each do |k, entry|
113
+ hsh[k] = entry.value
114
+ end
115
+
116
+ hsh
117
+ end
118
+
119
+ # Take the crap out of #inspect calls
120
+ def inspect
121
+ as_hash
122
+ end
123
+
124
+ private
125
+ def update_or_create_entry(key, value)
126
+ raise "Key must be a string or a symbol!" unless key.is_a?(String) or key.is_a?(Symbol)
127
+ load_entries_if_needed
128
+
129
+ @changes_made = true
130
+ @owner.updated_at = Time.now
131
+
132
+ if @entries[key]
133
+ @entries[key].value = value
134
+ else
135
+ new_entry = @options[:entry_class].new
136
+ set_entry_owner(new_entry)
137
+ new_entry.key = key
138
+ new_entry.value = value
139
+
140
+ @entries[key] = new_entry
141
+
142
+ value
143
+ end
144
+ end
145
+
146
+ # Since entries are lazy-loaded, this is called just before an operation on an entry happens and
147
+ # loads the rows only once per EavHash lifetime.
148
+ def load_entries_if_needed
149
+ if @entries.nil?
150
+ @entries = {}
151
+ rows_from_model = @owner.send("#{@options[:entry_assoc_name]}")
152
+ rows_from_model.each do |row|
153
+ @entries[row.key] = row
154
+ end
155
+ end
156
+
157
+ @entries
158
+ end
159
+
160
+ # Sets an entry's owner ID. This is called when we save attributes for a model which has just been
161
+ # created and not committed to the DB prior to having its EAV hash(es) modified
162
+ # @param [EavEntry] entry the entry whose owner to change
163
+ def set_entry_owner(entry)
164
+ entry.send "#{@options[:parent_assoc_name]}_id=", @owner.id
165
+ end
166
+ end
167
+ end
168
+ end