eav_hashes 1.0.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.
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