Skip to content

carlosparamio/mongo_odm

Repository files navigation

image:https://badge.fury.io/rb/mongo_odm.svg

mongo_odm

Flexible persistence module for any Ruby class to MongoDB.

Why another ODM for MongoDB?

* Fully compatible with Rails 3
* Use the Mongo ruby driver when possible (query syntax, cursors, indexes management...)
* Allow lazy loading of collections and queries nesting (concatenation of 'find' calls) to emulate ActiveRecord 3
* No association methods (for now): Just declare your own methods on models to fetch the related items
* Give support for dirty objects, validations, etc. through ActiveModel 3
* Automanage type conversions and default values
* Keep it as simple as possible

Basics

Other Mongo ODMs don’t require to explicitly define the possible schema of a model. I think this is necessary to help with type conversions (instanciate the right class for each attribute, and convert them to a Mongo compatible type when persisted). But it’s also possible to fill attributes with valid Mongo values without defining them as fields, and only the attributes whose values are different than the default values are stored as part of the document when saved.

A piece of code is better than a hundred of words:

# Establish connection; it uses localhost:27017 and database 'test' if not specified
MongoODM.config = {:host => 'localhost', :port => 27017, :database => "my_tests"}

class Shape
  include MongoODM::Document
  field :name
  field :x, Float, :default => 0.0
  field :y, Float, :default => 0.0
end

shape = Shape.new(:name => "Point", :x => 0, :y => 5)
shape.save

# Saves:
# { "_id"    : ObjectId("4be97178715dd2c4be000006"),
#   "_class" : "Shape",
#   "x"      : 0,
#   "y"      : 5,
#   "color"  : null,
#   "name"   : "Point"
# }

class Circle < Shape # This items are stored on the 'shapes' collection
  field :radius, Float, :default => 1.0
end

circle = Circle.new.save

# Saves:
# { "_id"    : ObjectId("4be97203715dd2c4be000007"),
#   "_class" : "Circle",
#   "x"      : 1,
#   "y"      : 1,
#   "color"  : null,
#   "radius" : 1 }

all_shapes = Shape.find # Returns a criteria object. It will execute the query and instance the objects once you iterate over it

all_shapes.to_a
# Returns all the shapes; notice they are of different classes:
# [ #<Shape x: 0.0, y: 5.0, color: nil, name: "Point", _id: {"$oid"=>"4be97178715dd2c4be000006"}>,
#   #<Circle x: 1.0, y: 1.0, color: nil, radius: 1.0, _id: {"$oid"=>"4be97293715dd2c4be000008"}> ]

In fact, you can instanciate any document stored as a hash to the appropiate class. The document just need to have the attribute “_class” set to the name of the class you want to use as the object type. Example:

MongoODM.instanciate({ :x => 12, :y => 5, '_class' => 'Circle' })

# Returns:
# #<Circle x: 12.0, y: 5.0, color: nil, radius: 1.0>

And because any query method returns a MongoODM::Criteria object, you can concatenate them to nest several conditions (like if they were ActiveRecord scopes):

Shape.find(:radius => 1).find({}, {:sort => [:color, :asc]}) # Returns a criteria object. Once you iterate over it, it will run a query with both the :radius selector and :sort order.

You can also define your own class methods that returns criteria objects, and concatenate them to obtain a single criteria with all the conditions merged in the calls order:

class Shape
  include MongoODM::Document

  def self.with_radius(n)
    find(:radius => n)
  end

  def self.ordered_by_color
    find({}, {:sort => [:color, :asc]})
  end
end

Shape.with_radius(1).ordered_by_color # Returns the same criteria than the previous example

Default values for fields can be either a fixed value, or a block, in which case the block will be called each time an object is instantiated. Example:

class Timestamp
  include MongoODM::Document

  field :value, Time, :default => lambda { Time.now }
  field :set, Set, :default => lambda { Set.new }
end

Take a look at the Mongo Ruby driver documentation for the ‘find’ method to see the available options:

api.mongodb.org/ruby/1.2.4/Mongo/Collection.html#find-instance_method

Collections

By default, mongo_odm stores data on a collection with the same name than the class, pluralized. In case of class inheritance, it uses the name of the parent class. You can override this behavior by setting a different collection for a class:

class Shape
  set_collection 'my_shapes'
end

Alternatively, you can pass a MongoODM::Collection instance to set_collection, to indicate not only a collection name, but also a different connection and/or database:

class Shape
  set_collection MongoODM::Collection.new(MongoODM.connection.db('another_database'), 'my_shapes')
end

References

You can use BSON::DBRef as the type of a field. This acts as a pointer to any other document in your database, at any collection. If you assign a MongoODM::Document instance to a BSON::DBRef field, it will be converted to a reference automatically. To instantiate any reference object, just call “dereference” on it. To convert any MongoODM::Document object to a reference, just call “to_dbref” on it.

You can even dereference a full array or hash that contains BSON::DBRef instances! It will dereference them at any level.

class Node
  include MongoODM::Document
  field :name
  field :parent, BSON::DBRef
  field :children, Array
end

root_node = Node.new(:name => 'root')
root_node.save
children1 = Node.new(:name => 'children1', :parent => root_node)
children1.save
root_node.children = [children1.to_dbref]
root_node.save

children1.parent   # Returns BSON::DBRef(namespace:"nodes", id: "4d60e8c83f5f19cf08000001")
root_node.children # Returns [BSON::DBRef(namespace:"nodes", id: "4d60e8c83f5f19cf08000002")]

children1.parent.dereference # Returns #<Node _id: BSON::ObjectId('4d60e8c83f5f19cf08000001'), children: [BSON::DBRef(namespace:"nodes", id: "4d60e8c83f5f19cf08000002")], name: "root", parent: nil>

root_node.children.dereference # Returns [#<Node _id: BSON::ObjectId('4d60e8c83f5f19cf08000002'), children: nil, name: "children1", parent: BSON::DBRef(namespace:"nodes", id: "4d60e8c83f5f19cf08000001")>]

Associations

To embed just one copy of another class, just define the field type of that class. The class just need to respond to the “type_cast” class method and the “to_mongo” instance method. Example:

class RGB
  def initialize(r, g, b)
    @r, @g, @b = r, g, b
  end

  def inspect
    "RGB(#{@r},#{@g},#{@b})"
  end

  def to_mongo
    [@r, @g, @b]
  end

  def self.type_cast(value)
    return nil if value.nil?
    return value if value.is_a?(RGB)
    return new(value[0], value[1], value[2]) if value.is_a?(Array)
  end
end

class Color
  include MongoODM::Document
  field :name
  field :rgb, RGB

  index :name, :unique => true
end

Color.create_indexes # You can also use MongoODM.create_indexes to create all the indexes at all classes at the same time

color = Color.new(:name => "red", :rgb => RGB.new(255,0,0))
color.save

# Saves:
# {"_class":"Color","name":"red","rgb":[255,0,0],"_id":{"$oid": "4bf070fb715dd271c2000001"}}

red = Color.find({:name => "red"}).first

# Returns:
# #<Color name: "red", rgb: RGB(255,0,0), _id: {"$oid"=>"4bf070fb715dd271c2000001"}>

Of course, if the embedded object’s class includes the MongoODM::Document module, you don’t need to define those methods. Just define the field as that class:

class RGB
  include MongoODM::Document
  field :r, Fixnum
  field :g, Fixnum
  field :b, Fixnum
end

class Color
  include MongoODM::Document
  field :name
  field :rgb, RGB
end

color = Color.new(:name => "red", :rgb => RGB.new(:r => 255, :g => 0, :b => 0))
color.save

# Saves:
# {"_class":"Color","name":"red","rgb":{"_class":"RGB","r":255,"g":0,"b":0},"_id":{"$oid": "4bf073e3715dd27212000001"}}

red = Color.find({:name => "red"}).first

# Returns:
# #<Color name: "red", rgb: #<RGB r: 255, g: 0, b: 0>, _id: {"$oid"=>"4bf073e3715dd27212000001"}>

If you want to save a collection of objects, just define the field as an Array. You can even store objects of different types!

class Shape
  include MongoODM::Document
  field :x, Float
  field :y, Float
end

class Circle < Shape
  include MongoODM::Document
  field :radius, Float
end

class Line < Shape
  include MongoODM::Document
  field :dx, Float
  field :dy, Float
end

class Draw
  include MongoODM::Document
  field :objects, Array
end

circle1 = Circle.new(:x => 1, :y => 1, :radius => 10)
circle2 = Circle.new(:x => 2, :y => 2, :radius => 20)
line = Line.new(:x => 0, :y => 0, :dx => 10, :dy => 5)

draw = Draw.new(:objects => [circle1, line, circle2])
draw.save

# Saves:
# { "_class" : "Draw",
#   "objects" : [ { "_class" : "Circle",
#                   "x" : 1.0,
#                   "y" : 1.0,
#                   "color" : null,
#                   "radius" : 10.0 },
#                 { "_class" : "Line",
#                   "x" : 0.0,
#                   "y" : 0.0,
#                   "color" : null,
#                   "dx" : 10.0,
#                   "dy" : 5.0},
#                 { "_class" : "Circle",
#                   "x" : 2.0,
#                   "y" : 2.0,
#                   "color" : null,
#                   "radius" : 20.0 } ],
#   "_id":{"$oid": "4bf0775d715dd2725a000001"}}

Draw.find_one

# Returns
# #<Draw objects: [#<Circle x: 1.0, y: 1.0, color: nil, radius: 10.0>, #<Line x: 0.0, y: 0.0, color: nil, dx: 10.0, dy: 5.0>, #<Circle x: 2.0, y: 2.0, color: nil, radius: 20.0>], _id: {"$oid"=>"4bf0775d715dd2725a000001"}>

To reference the associated objects instead of embed them, you can use BSON::DBRef (to reference one), Array (to reference several), and others:

class Flag
  include MongoODM::Document
  field :colors_refs, Array, :default => []

  def add_color(color)
    colors_refs << color.to_dbref
  end

  def colors
    colors_refs.dereference
  end
end

class Color
  include MongoODM::Document
  field :name
end

color_red = Color.new(:name => "red")
color_red.save
color_green = Color.new(:name => "green")
color_green.save

flag = Flag.new
flag.add_color(color_red)
flag.add_color(color_green)
flag.save

# Saves:
# { "_id"         : ObjectId("4be96c15715dd2c4be000003"),
#   "_class"      : "Flag",
#   "colors_refs" : [
#                     { "$ns" : "colors",
#                       "$id" : {
#                         "$oid" : "4d60ea4e3f5f19cf10000001"
#                        }
#                     },
#                     { "$ns" : "colors",
#                       "$id" : {
#                         "$oid" : "4d60ea4e3f5f19cf10000002"
#                       }
#                     }
#                   ]
# }

flag.colors # Returns [#<Color _id: BSON::ObjectId('4d60ea4e3f5f19cf10000001'), name: "red">, #<Color _id: BSON::ObjectId('4d60ea4e3f5f19cf10000002'), name: "green">]

flag.colors

# Returns a criteria object that wraps a cursor

flag.colors.to_a

# Returns:
# [#<Color name: "red", _id: {"$oid"=>"4be96bfe715dd2c4be000001"}>, #<Color name: "green", _id: {"$oid"=>"4be96c08715dd2c4be000002"}>]

Or you can build your custon methods. Example:

class Flag
  include MongoODM::Document
  field :colors_ids, Array

  def colors
    Color.find(:_id => {'$in' => colors_ids})
  end
end

class Color
  include MongoODM::Document
  field :name
end

Color.new(:name => "red").save
Color.new(:name => "green").save

flag = Flag.new(:colors_ids => [ Color.find_one(:name => "red").id, Color.find_one(:name => "green").id ])
flag.save

# Saves:
# { "_id"        : ObjectId("4be96c15715dd2c4be000003"),
#   "_class"     : "Flag",
#   "colors_ids" : [ ObjectId("4be96bfe715dd2c4be000001"), ObjectId("4be96c08715dd2c4be000002") ]
# }

flag.colors

# Returns a criteria object that wraps a cursor

flag.colors.to_a

# Returns:
# [#<Color name: "red", _id: {"$oid"=>"4be96bfe715dd2c4be000001"}>, #<Color name: "green", _id: {"$oid"=>"4be96c08715dd2c4be000002"}>]

Callbacks

For now, the available callbacks are: after_initialize, before_save, after_save

Example:

class User
  include MongoODM::Document

  field :encrypted_password
  attr_accessor :password

  before_save :encrypt_password

  def encrypt_password
    return if self.password.blank?
    self.encrypted_password = encrypt(password)
  end
  protected :encrypt_password
end

Validations

All the validation methods defined in ActiveModel::Validations are included

Example:

class User
  include MongoODM::Document
  field :email

  validates_presence_of :email
  validates_uniqueness_of :email, :case_sensitive => false
  validates_format_of :email, :with => /^([a-zA-Z0-9_\.\-\+])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/
end

Dirty

All the dirty object methods defined in ActiveModel::Dirty are included

Example:

class User
  include MongoODM::Document
  field :email
end

user = User.new
user.email = "hello@h1labs.com"
user.email_changed? # Returns true
user.email_change # Returns [nil, "hello@h1labs.com"]
user.changes # Returns {"email" => [nil, "hello@h1labs.com"]}

Others

Access to a cursor to the whole collection:

User.cursor

Use cursor methods directly on the class:

User.has_next?
User.each{...}
User.next_document
User.rewind!
...

TODO

* Allow to specify different database connections with each document definition
* Increase rspec coverage
* Document, document, document!
* Create useful modules to make common operations easier (versioning, trees, etc)

More

For now, take a look at the Mongo Ruby driver syntax:

api.mongodb.org/ruby/1.2.4/index.html

Credits

Carlos Paramio, carlosparamio.com.

See CONTRIBUTORS file for a list of contributions.

License

See LICENSE file for details.

Star History

image:https://api.star-history.com/svg?repos=carlosparamio/mongo_odm&type=Date

About

Flexible persistence module for any Ruby class to MongoDB

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •  

Languages