description
The Property gem lets you define “properties” for your models. Properties behave like normal rails attributes with the following differences:
what you gain
- they should be faster (less type casting)
- you can add properties to sub-classes without affecting the parent class
- you can dynamically define properties in model instances when needed (virtual classes)
- you can easily define complex indexes
what you loose
- they are harder to use in queries (must use index tables)
- you could directly index the json data, but that might not be optimal
wraps model properties into a single database column and declare properties from within the model.
This class is 100% compatible with ActiveRecord. This means that if you have been using a model with class attributes, you should be able migrate the data and copy paste the table definition from the schema into your model and it should work as before, except for sql queries of course and self['foo'] accessors.
source code
The code is on github
Usage
Install with:
$ sudo gem install property
You then need to create a migration to add a ‘text’ field named ‘properties’ to
your model. Something like this:
class AddPropertyToContact < ActiveRecord::Migration
def self.up
add_column :contacts, :properties, :text
end
def self.down
remove_column :contacts, :properties
end
end
Once your database is ready, you need to declare the property columns:
class Contact < ActiveRecord::Base
include Property
property do |p|
p.string 'first_name', 'name', 'phone'
p.datetime 'contacted_at', :default => Proc.new {Time.now}
p.string 'encoding', :default => :get_encoding
end
end
You can now read property values with:
@contact.prop['first_name']
@contact.first_name
And set them with:
@contact.update_attributes('first_name' => 'Mahatma')
@contact.prop['name'] = 'Gandhi'
@contact.name = 'Gandhi'
If you want to read as fast as possible, prop['attribute'] is your friend.
indexing
Of course storing raw data is cool, but what happens if you need to sort ? or build queries related to the values in the property hash ?
Simply create index tables:
class AddStringIndexToContact < ActiveRecord::Migration
def self.up
# Read the name as "index string in contacts"
create_table 'i_string_contacts' do |t|
t.integer 'contact_id'
t.string 'key'
t.string 'value'
end
end
def self.down
drop_table :i_string_contacts
end
end
When you have migrated your database, you can start setting indexes on your properties, either by adding :indexed => true or by using a Proc:
class Contact < ActiveRecord::Base
include Property
property do |p|
p.string 'name', :indexed => true
p.string 'first_name', :index => Proc.new {|rec| { 'fullname' => rec.fullname }}
p.index(:string) do |record|
{
'fulltext' => "name:#{record.name} first_name:#{record.first_name}",
"name_#{record.lang}" => record.name
}
end
end
end
You can also use a Proc on a single property or build dynamic keys. In fact you can do whatever you want inside the index block as long as you return a Hash with string keys pointing to values compatible with the type for ‘value’ in your corresponding table (:string ==> i_string_…).
Property does not provide helpers to search and sort using the keys defined here, but if someone wants to fix this, send your code !
integration with versioning
It’s nice to have properties, but it would be even nicer if we could easily enable versioning. This is very easy: you just need to specify the name of a method to reach the model that will hold the properties storage. Here is an example that uses the brand new Versions gem:
class Contact < ActiveRecord::Base
include Versions::Multi
has_multiple :versions
include Property
store_properties_in :version
property do |t|
t.string 'name', :indexed => true
t.string 'first_name'
t.integer 'age'
t.datetime 'seen_at', :default => Proc.new { Time.now }
end
# Tell the property indexer how to find the index records. In this example, simply find the records where 'version_id' is equal to version.id.
def index_reader
{'version_id' => version.id}
end
# We store the 'contact_id' in the index to find Contact from the index without passing by the Version class.
def index_writer
{'version_id' => version.id, 'contact_id' => self.id}
end
end
Your Version model would look like this:
class Version < ActiveRecord::Base
include Versions::Auto
def should_clone?
true # always clone on update
end
end
And that’s it. You can use the Contact class as if it had ‘name’ and ‘first_name’ attributes and the content will be transparently versioned and indexed with a ‘contact_id’ key.
advanced topics
time and dates
We have changed the ‘to_json’ method in Time so that it’s fast and works automatically when parsing the string (no typecasting).
Without Property, Time.now.utc.to_json looks like this out of rails:
"Thu Feb 11 17:50:18 UTC 2010"
With rails you get:
"2010/02/11 17:50:18 +0000"
With Property, you will have:
{"data":"2010-02-11 17:50:18","json_class":"Time"}
Note that this is the only version where this is true:
t = Time.utc(2010,02,11,17,50,18)
t == JSON.parse(t.to_json)
serialization engine
After intensive testing of YAML, Marshal and JSON serialization (all available and tested in the gem), we have decided to use JSON because it’s the fastest solution in real-world cases and it’s less problematic on the long run.
In case you have read that Marshal is faster then JSON, this might be the case if:
- you don’t read (decode) more often then you encode (not the case in a web app)
- the tests don’t include the ‘pack’ and ‘unpack’ necessary to move the data in the database
My own benchmarks show:
user system total real
JSON 0.710000 0.010000 0.720000 ( 0.720144)
Marshal 0.830000 0.050000 0.880000 ( 0.888717)
You can download the “testfile” if you want.
PS: In fact, Marshal is faster if you encode lots of tiny objects, but there are many disadvantages to using Marshal encoding that the tiny speed difference is just not worth it.