thingtank: couchrest docs with multiple characters
ThingTank is a library that uses couchrest and couchrest model to create arbitrary objects that may have multiple characters. The characters determine the properties and they can be mixed and matched at will.
Installation
thingtank is tested with ruby 1.8.7, 1.9.2 and above.
Install it as a gem:
sudo gem install thingtank
or in rvm:
gem install thingtank
Examples:
Imagine a Caesar is born
class Born < ThingTank::Character
property :birth_date, :alias => :born_at
property :birth_place
validates_presence_of :birth_date # make sure a date is given
end
just in case he might die....we might want to have a date and maybe even a place
class Dead < ThingTank::Character
property :date_of_death
property :place_of_death
validates_presence_of :date_of_death
end
and then he needs a name
class Person < ThingTank::Character
property :name
property :gender
validates_presence_of :name
end
now we can create julius
julius = ThingTank.create :gender => :m, :name => 'Gaius Iulius Caesar'
julius["birth_date"] = "100 BC"
julius["birth_place"] = "Rome"
julius.is(Born)
julius.could_be? Person # => true
julius.is? Person # => false
julius.is Person
julius.is? Person # => true
julius.is(Born)["gender"] # => nil (gender is not a property of born)
julius.is(Born)["birth_date"] # => "100BC"
id = julius.id
later...
julius = ThingTank.get id
julius["birth_date"] # => "100BC"
julius.is? Person # => true
when he is adult, he wants to marry. now things are getting a bit more complicated:
# he needs a marriage and a women
class Married < ThingTank::Character
property :date # the date of the marriage
property :end # when the marriage ended
property :spouse # doc_id of the spouse
property :state # state of the marriage
validates_presence_of :date
validates_presence_of :spouse
validate :spouse_should_be_a_person
# ensure that 'spouse' is a doc_id of a Person
def spouse_should_be_a_person
Person.get(self["spouse"]).valid? # loads doc as character Person and validates, same as ThingTank.get(self["spouse"]).as(Person).valid?
end
end
we want easy access to the name of the spouse
class Spouse < ThingTank::Character
property :married
property :married_state
validate :spouse_should_be_married
validates :married, :character => Married # doc must have "married" character
def married
self["married"] # contains a Married character
end
def spouse_should_be_married
married["spouse"] == self["_id"]
end
def name
self["married_state"] == "married" ?
Person.get(married["spouse"]).name :
nil
end
def ex
self["married_state"] == "divorced" ?
Person.get(married["spouse"]).name :
nil
end
end
now we could easily get julius married
conny = ThingTank.create :gender => "f", :name => 'Cornelia', :characters => ['Person']
julius["married"] = {"date" => "84 BC", "spouse" => conny.id}
julius["married_state"] = "married"
julius.with("married").is(Married).valid? # => true # "married" is a property that has the Married character
julius.has(Spouse).valid? # #has is an alias of #is
julius.save
julius.reload
julius.has(Spouse).name # => 'Cornelia'
while that is nice, let see if we could make it more comfortable:
class Married
# marry a doc or hash
def marry(person)
person = ThingTank.new(person) if person.is_a?(Hash)
person.save # should have a doc_id
# assign the doc_id to spouse
self["spouse"] = person["_id"]
self["state"] = 'married'
_doc["married_state"] = 'married'
unless person["married"] && person.last_character(Married, "married").spouse == _doc["_id"]
person.add_character(Married, "married") do |m|
m.date = self["date"]
m.marry _doc
end
person.is(Spouse)
person.save
end
end
def divorce(date)
self["state"] = 'divorced'
self['end'] = date
_doc.save
spouse = Spouse.get(self["spouse"])
if spouse.married_state == "married"
spouse.divorce(date)
end
end
end
class Spouse
def married(&code)
_doc.last_character Married, "married", &code
end
def divorce(date)
self["married_state"] = 'divorced'
married { |m| m.divorce(date) }
end
end
class Person
def marry(date, person)
_doc.add_character Married, "married", do |m|
m.date = "84 BC"
m.marry person
end
end
end
it now becomes much less work and Cornelia also knows that she is married to Julius
julius.as(Person).marry "84 BC", :gender => "f", :name => 'Cornelia'
julius.save
julius.reload
julius.has(Spouse).name # => 'Cornelia'
julius.has(Spouse).married_state # => 'married'
conny_id = julius.last_character(Married,"married").spouse
conny = ThingTank.get conny_id
conny.has(Spouse).married_state # => 'married'
julius could even marry a second time, i.e. marriage becomes an Array of Marriage objects
julius.as(Person).marry "68-65 BC", :gender => "f", :name => 'Pompeia'
Person.get(julius["married"].first["spouse"]).name # => 'Cornelia'
Person.get(julius["married"].last["spouse"]).name # => 'Pompeia'
julius.has(Spouse).name # => 'Pompeia'
julius["married"].first["state"] # => 'married'
julius["married"].last["state"] # => 'married'
julius["married"].size # => 2
# ouch, two women!
julius is still married with Cornelia but he should not
if Cornelia died before his second marriage, it would not be a problem:
class Dead
# all callbacks of characters are called and defined like corresponding callbacks of the doc
before_save do
if _doc.is?(Spouse) && _doc['married_state'] == 'married'
Spouse.get(_doc.last_character(Married, 'married').spouse).widowed(self["date_of_death"])
end
true
end
end
class Person
def dies(date)
_doc.is(Dead) do |d|
d.date_of_death = date
end
end
end
class Married
def widow(date)
self["state"] = 'widowed'
self['end'] = date
_doc.save
end
end
class Spouse
def widowed(date)
self["married_state"] = 'widowed'
married { |m| m.widow(date) }
end
end
julius.as(Person).marry "84 BC", :gender => "f", :name => 'Cornelia'
julius.save
julius.reload
conny = ThingTank.get julius["married"]["spouse"]
conny.save
conny.reload
conny.as(Person).dies "68-65 BC"
conny.save
julius.reload
julius.as(Person).marry "68-65 BC", :gender => "f", :name => 'Pompeia'
julius["married"].size # => 2
Person.get(julius["married"].first["spouse"]).name # => 'Cornelia'
julius["married"].first["state"] # => 'widowed'
since julius is immortal, no one should be able to destroy him:
class Undestroyable < ThingTank::Character
before_destroy do
false # never allow to destroy
end
end
julius.is(Undestroyable)
julius.save # save the character
id = julius.id
julius = ThingTank.get id
julius.as(Undestroyable).destroy
ThingTank.get(id).nil? # => julius is still there
julius.destroy
ThingTank.get(id).nil? # => julius is still there
You may subclass ThingTank to do further separation and mix the native properties of ThingTanks / its subclasses with the characters properties.
Hints:
The main idea is that every couch document could have many characters at the same time. The implementation is that the document is an object of ThingTank or a subclass of it. ThingTank is compatible to couchrest_model with some helper methods. Most of the time you don't want to define properties for the ThingTank class or a subclass. You may store any key => value freely within the document by using the [] and []= methods.
The concept is inspired by the way the go language defines interfaces. With ThingTank you may thing of the main doc as a Hash. Then the characters are "interfaces", that define which properties a doc would need to fullfill the character ("to have the character"). But that alone is not suffient. You also need to tell the doc that it will have the character from now on. This information will be stored in the "characters" property of the doc. You might remove this statement with or without affecting the properties the character cares about. But only if the doc should have the character you might interact with the subseet of his properties that the character cares about via the character. A doc might combine different characters at the same time. There might be even different characters that care about the same properties. If so you should take care that there are no conflicting actions taking place at the same update.
The characters are all subclasses of the ThingTank::Character class and are compatible with couchrest_model as well but they won't interact with the database directly but only via their document (instance of ThingTank). The characters is where all validation, callbacks, properties and additional methods should go into. They do all the hard work, but they aren't loaded automatically but only if you call them via the ThingTank#as method. Then the properties that they care about are copied from the doc to the character instance. When you interact with the character object its properties go out of sync with the original doc so here are some hints how to handle this dirty state and how to get them back to the doc.
Characters may also interact via the doc, but care has to be taken in order to save the changes back to doc properly and to inform the affected character about the changes.
- Use Character#to_doc to return the changed date from the character to the doc without saving the doc.
- Use Character#reload to load the (possibly changed) data from the doc to the character object.
- Use Character#reload! to load the (possibly changed) data from the database. The doc with be reloaded from the database and then fill the character object.
- Use Character#save or ThingTank#save to save the doc with all changes
- Use ThingTank#add_character to add a Character and pass it a code block, so that all the changes go back to the doc at the end of the code block
- Use ThingTank#as to work with a certain character of the doc and pass it a code block, so that all the changes go back to the doc at the end of the code block
- Use ThingTank#is if the properties for the character are already in the doc and you just want it to let it have the character
- Use ThingTank#get to get the doc out of the database (and then use ThingTank#as to handle it as a character)
- Views are stored and called via the ThingTank class
- Pass ThingTank#add_character as second parameter the name of the property if you want the doc to have a property that has a certain character
- Use ThingTank#with to use the character of a certain property
- Use the _doc method within a character method to access the doc and _doc.id for the id
- Look at the examples and tests
Contributing to thingtank
- Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
- Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
- Fork the project
- Start a feature/bugfix branch
- Commit and push until you are happy with your contribution
- Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
- Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
Copyright
Copyright (c) 2012 Marc Rene Arns. See LICENSE.txt for further details.