thingtank: couchrest docs with multiple characters

Build Status

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 (c) 2012 Marc Rene Arns. See LICENSE.txt for further details.