Permalink
Fetching contributors…
Cannot retrieve contributors at this time
890 lines (818 sloc) 24.4 KB
# Author:: Couchbase <info@couchbase.com>
# Copyright:: 2012 Couchbase, Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require 'digest/md5'
require 'couchbase'
require 'couchbase/active_model'
require 'couchbase/model/version'
require 'couchbase/model/uuid'
require 'couchbase/model/configuration'
require 'active_model'
unless Object.respond_to?(:singleton_class)
require 'couchbase/model/ext/singleton_class'
end
unless ''.respond_to?(:constantize)
require 'couchbase/model/ext/constantize'
end
unless ''.respond_to?(:camelize)
require 'couchbase/model/ext/camelize'
end
module Couchbase
# @since 0.0.1
class Error::MissingId < Error::Base; end
# @since 0.4.0
class Error::RecordInvalid < Error::Base
attr_reader :record
def initialize(record)
@record = record
if @record.errors
super(@record.errors.full_messages.join(', '))
else
super('Record invalid')
end
end
end
# Declarative layer for Couchbase gem
#
# @since 0.0.1
#
# require 'couchbase/model'
#
# class Post < Couchbase::Model
# attribute :title
# attribute :body
# attribute :draft
# end
#
# p = Post.new(:id => 'hello-world',
# :title => 'Hello world',
# :draft => true)
# p.save
# p = Post.find('hello-world')
# p.body = "Once upon the times...."
# p.save
# p.update(:draft => false)
# Post.bucket.get('hello-world') #=> {"title"=>"Hello world", "draft"=>false,
# # "body"=>"Once upon the times...."}
#
# You can also let the library generate the unique identifier for you:
#
# p = Post.create(:title => 'How to generate ID',
# :body => 'Open up the editor...')
# p.id #=> "74f43c3116e788d09853226603000809"
#
# There are several algorithms available. By default it use `:sequential`
# algorithm, but you can change it to more suitable one for you:
#
# class Post < Couchbase::Model
# attribute :title
# attribute :body
# attribute :draft
#
# uuid_algorithm :random
# end
#
# You can define connection options on per model basis:
#
# class Post < Couchbase::Model
# attribute :title
# attribute :body
# attribute :draft
#
# connect :port => 80, :bucket => 'blog'
# end
class Model
# Each model must have identifier
#
# @since 0.0.1
attr_accessor :id
# @since 0.2.0
attr_reader :key
# @since 0.2.0
attr_reader :value
# @since 0.2.0
attr_reader :doc
# @since 0.2.0
attr_reader :meta
# @since 0.4.5
attr_reader :errors
# @since 0.4.5
attr_reader :raw
# @private Container for all attributes with defaults of all subclasses
@@attributes = {}
# @private Container for all view names of all subclasses
@@views = {}
# Use custom connection options
#
# @since 0.0.1
#
# @param [String, Hash, Array] options options for establishing
# connection.
# @return [Couchbase::Bucket]
#
# @see Couchbase::Bucket#initialize
#
# @example Choose specific bucket
# class Post < Couchbase::Model
# connect :bucket => 'posts'
# ...
# end
def self.connect(*options)
self.bucket = Couchbase.connect(*options)
end
# Associate custom design document with the model
#
# Design document is the special document which contains views, the
# chunks of code for building map/reduce indexes. When this method
# called without argument, it just returns the effective design document
# name.
#
# @since 0.1.0
#
# @see http://www.couchbase.com/docs/couchbase-manual-2.0/couchbase-views.html
#
# @param [String, Symbol] name the name for the design document. By
# default underscored model name is used.
# @return [String] the effective design document
#
# @example Choose specific design document name
# class Post < Couchbase::Model
# design_document :my_posts
# ...
# end
def self.design_document(name = nil)
if name
@_design_doc = name.to_s
else
@_design_doc ||= begin
name = self.name.dup
name.gsub!(/::/, '_')
name.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
name.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
name.downcase!
end
end
end
def self.defaults(options = nil)
if options
@_defaults = options
else
@_defaults || {}
end
end
# Ensure that design document is up to date.
#
# @since 0.1.0
#
# This method also cares about organizing view in separate javascript
# files. The general structure is the following (+[root]+ is the
# directory, one of the {Model::Configuration.design_documents_paths}):
#
# [root]
# |
# `- link
# | |
# | `- by_created_at
# | | |
# | | `- map.js
# | |
# | `- by_session_id
# | | |
# | | `- map.js
# | |
# | `- total_views
# | | |
# | | `- map.js
# | | |
# | | `- reduce.js
#
# The directory structure above demonstrate layout for design document
# with id +_design/link+ and three views: +by_created_at+,
# +by_session_id` and `total_views`.
def self.ensure_design_document!
unless Configuration.design_documents_paths
raise 'Configuration.design_documents_path must be directory'
end
doc = {'_id' => "_design/#{design_document}", 'views' => {}}
digest = Digest::MD5.new
mtime = 0
views.each do |name, _|
doc['views'][name] = {}
doc['spatial'] = {}
['map', 'reduce', 'spatial'].each do |type|
Configuration.design_documents_paths.each do |path|
ff = File.join(path, design_document.to_s, name.to_s, "#{type}.js")
if File.file?(ff)
contents = File.read(ff).gsub(/^\s*\/\/.*$\n\r?/, '').strip
next if contents.empty?
mtime = [mtime, File.mtime(ff).to_i].max
digest << contents
case type
when 'map', 'reduce'
doc['views'][name][type] = contents
when 'spatial'
doc['spatial'][name] = contents
end
break # pick first matching file
end
end
end
end
doc['views'].delete_if {|_, v| v.empty? }
doc.delete('spatial') if doc['spatial'] && doc['spatial'].empty?
doc['signature'] = digest.to_s
doc['timestamp'] = mtime
if doc['signature'] != thread_storage[:signature] && doc['timestamp'] > thread_storage[:timestamp].to_i
current_doc = bucket.design_docs[design_document.to_s]
if current_doc.nil? || (current_doc['signature'] != doc['signature'] && doc['timestamp'] > current_doc[:timestamp].to_i)
bucket.save_design_doc(doc)
current_doc = doc
end
thread_storage[:signature] = current_doc['signature']
thread_storage[:timestamp] = current_doc['timestamp'].to_i
end
end
# Choose the UUID generation algorithms
#
# @since 0.0.1
#
# @param [Symbol] algorithm (:sequential) one of the available
# algorithms.
#
# @see Couchbase::UUID#next
#
# @example Select :random UUID generation algorithm
# class Post < Couchbase::Model
# uuid_algorithm :random
# ...
# end
#
# @return [Symbol]
def self.uuid_algorithm(algorithm)
self.thread_storage[:uuid_algorithm] = algorithm
end
def read_attribute(attr_name)
@_attributes[attr_name]
end
alias :[] :read_attribute
def write_attribute(attr_name, value)
attribute_will_change!(attr_name) unless @_attributes[attr_name] == value
@_attributes[attr_name] = value
end
alias :[]= :write_attribute
# Defines an attribute for the model
#
# @since 0.0.1
#
# @param [Symbol, String] name name of the attribute
#
# @example Define some attributes for a model
# class Post < Couchbase::Model
# attribute :title
# attribute :body
# attribute :published_at
# end
#
# post = Post.new(:title => 'Hello world',
# :body => 'This is the first example...',
# :published_at => Time.now)
def self.attribute(*names)
options = {}
if names.last.is_a?(Hash)
options = names.pop
end
names.each do |name|
name = name.to_sym
attributes[name] = options[:default]
next if self.instance_methods.include?(name)
define_method(name) do
read_attribute(name)
end
define_method(:"#{name}=") do |value|
write_attribute(name, value)
end
end
end
# Defines a view for the model
#
# @since 0.0.1
#
# @param [Symbol, String, Array] names names of the views
# @param [Hash] options options passed to the {Couchbase::View}
#
# @example Define some views for a model
# class Post < Couchbase::Model
# view :all, :published
# view :by_rating, :include_docs => false
# end
#
# post = Post.find("hello")
# post.by_rating.each do |r|
# # ...
# end
def self.view(*names)
options = {:wrapper_class => self, :include_docs => true}
if names.last.is_a?(Hash)
options.update(names.pop)
end
is_spatial = options.delete(:spatial)
names.each do |name|
path = '_design/%s/_%s/%s' % [design_document, is_spatial ? 'spatial' : 'view', name]
views[name] = lambda do |*params|
params = options.merge(params.first || {})
View.new(bucket, path, params)
end
singleton_class.send(:define_method, name, &views[name])
end
end
# Defines a belongs_to association for the model
#
# @since 0.3.0
#
# @param [Symbol, String] name name of the associated model
# @param [Hash] options association options
# @option options [String, Symbol] :class_name the name of the
# association class
#
# @example Define some association for a model
# class Brewery < Couchbase::Model
# attribute :name
# end
#
# class Beer < Couchbase::Model
# attribute :name, :brewery_id
# belongs_to :brewery
# end
#
# Beer.find("heineken").brewery.name
def self.belongs_to(name, options = {})
ref = "#{name}_id"
attribute(ref)
assoc = (options[:class_name] || name).to_s.camelize.constantize
# Define reader
define_method(name) do
begin
assoc.find(self.send(ref))
rescue Couchbase::Error::NotFound
end
end
# Define writer
attr_writer name
define_method(:"#{name}=") do |value|
if value
self.send("#{ref}=", value.id)
else
self.send("#{ref}=", nil)
end
instance_variable_set("@#{name}", value)
end
end
class << self
def _find(quiet, *ids)
wants_array = ids.first.kind_of?(Array)
ids = ids.flatten.compact.uniq
if ids.empty?
raise Couchbase::Error::NotFound unless quiet
else
res = bucket.get(ids, :quiet => quiet, :extended => true).map do |id, (obj, flags, cas)|
obj = {:raw => obj} unless obj.is_a?(Hash)
new({:id => id, :meta => {'flags' => flags, 'cas' => cas}}.merge(obj))
end
wants_array ? res : res.first
end
end
private :_find
end
# Find the model using +id+ attribute
#
# @since 0.0.1
#
# @param [String, Symbol, Array] id model identificator(s)
# @return [Couchbase::Model, Array] an instance of the model, or an array of instances
# @raise [Couchbase::Error::NotFound] when given key isn't exist
#
# @example Find model using +id+
# post = Post.find('the-id')
#
# @example Find multiple models using +id+
# post = Post.find('one', 'two')
def self.find(*id)
_find(false, *id)
end
# Find the model using +id+ attribute
#
# Unlike {Couchbase::Model.find}, this method won't raise
# {Couchbase::Error::NotFound} error when key doesn't exist in the
# bucket
#
# @since 0.1.0
#
# @param [String, Symbol] id model identificator(s)
# @return [Couchbase::Model, Array, nil] an instance of the model, an array
# of found instances of the model, or +nil+ if
# given key isn't exist
#
# @example Find model using +id+
# post = Post.find_by_id('the-id')
# @example Find multiple models using +id+
# posts = Post.find_by_id(['the-id', 'the-id2'])
def self.find_by_id(*id)
_find(true, *id)
end
# Create the model with given attributes
#
# @since 0.0.1
#
# @param [Hash] args attribute-value pairs for the object
# @return [Couchbase::Model, false] an instance of the model
def self.create(*args)
new(*args).create
end
# Creates an object just like {{Model.create} but raises an exception if
# the record is invalid.
#
# @since 0.5.1
# @raise [Couchbase::Error::RecordInvalid] if the instance is invalid
def self.create!(*args)
new(*args).create!
end
# Constructor for all subclasses of Couchbase::Model
#
# @since 0.0.1
#
# Optionally takes a Hash of attribute value pairs.
#
# @param [Hash] attrs attribute-value pairs
def initialize(attrs = {})
@errors = ::ActiveModel::Errors.new(self) if defined?(::ActiveModel)
@_attributes = ::Hash.new do |h, k|
default = self.class.attributes[k]
h[k] = if default.respond_to?(:call)
default.call
else
default
end
end
case attrs
when Hash
if defined?(HashWithIndifferentAccess) && !attrs.is_a?(HashWithIndifferentAccess)
if attrs.respond_to?(:with_indifferent_access)
attrs = attrs.with_indifferent_access
end
end
@id = attrs.delete(:id)
@key = attrs.delete(:key)
@value = attrs.delete(:value)
@doc = attrs.delete(:doc)
@meta = attrs.delete(:meta)
@raw = attrs.delete(:raw)
assign_attributes(@doc || attrs)
@previously_changed = nil
@changed_attributes.clear unless @changed_attributes.nil?
else
@raw = attrs
end
end
# Create this model and assign new id if necessary
#
# @since 0.0.1
#
# @return [Couchbase::Model, false] newly created object
#
# @raise [Couchbase::Error::KeyExists] if model with the same +id+
# exists in the bucket
#
# @example Create the instance of the Post model
# p = Post.new(:title => 'Hello world', :draft => true)
# p.create
def create(options = {})
@id ||= Couchbase::Model::UUID.generator.next(1, model.thread_storage[:uuid_algorithm])
return false if failed_validations?(options)
options = model.defaults.merge(options)
value = (options[:format] == :plain) ? @raw : attributes_with_values
unless @meta
@meta = {}
if @meta.respond_to?(:with_indifferent_access)
@meta = @meta.with_indifferent_access
end
end
@meta['cas'] = model.bucket.add(@id, value, options)
self
end
# Creates an object just like {{Model#create} but raises an exception if
# the record is invalid.
#
# @since 0.5.1
#
# @raise [Couchbase::Error::RecordInvalid] if the instance is invalid
def create!(options = {})
create(options) || raise(Couchbase::Error::RecordInvalid.new(self))
end
# Create or update this object based on the state of #new?.
#
# @since 0.0.1
#
# @param [Hash] options options for operation, see
# {{Couchbase::Bucket#set}}
#
# @return [Couchbase::Model, false] saved object or false if there
# are validation errors
#
# @example Update the Post model
# p = Post.find('hello-world')
# p.draft = false
# p.save
#
# @example Use CAS value for optimistic lock
# p = Post.find('hello-world')
# p.draft = false
# p.save('cas' => p.meta['cas'])
#
def save(options = {})
return create(options) unless @meta
return false if failed_validations?(options)
options = model.defaults.merge(options)
value = (options[:format] == :plain) ? @raw : attributes_with_values
@meta['cas'] = model.bucket.replace(@id, value, options)
@previously_changed = changes
@changed_attributes.clear unless @changed_attributes.nil?
self
end
# Creates an object just like {{Model#save} but raises an exception if
# the record is invalid.
#
# @since 0.5.1
#
# @raise [Couchbase::Error::RecordInvalid] if the instance is invalid
def save!(options = {})
save(options) || raise(Couchbase::Error::RecordInvalid.new(self))
end
# Update this object, optionally accepting new attributes.
#
# @since 0.0.1
#
# @param [Hash] attrs Attribute value pairs to use for the updated
# version
# @param [Hash] options options for operation, see
# {{Couchbase::Bucket#set}}
# @return [Couchbase::Model] The updated object
def update(attrs, options = {})
assign_attributes(attrs)
save(options)
end
alias :update_attributes :update
# Delete this object from the bucket
#
# @since 0.0.1
#
# @note This method will reset +id+ attribute
#
# @param [Hash] options options for operation, see
# {{Couchbase::Bucket#delete}}
# @return [Couchbase::Model] Returns a reference of itself.
#
# @example Delete the Post model
# p = Post.find('hello-world')
# p.delete
def delete(options = {})
raise Couchbase::Error::MissingId, 'missing id attribute' unless @id
model.bucket.delete(@id, options)
@id = nil
@meta = nil
self
end
alias :destroy :delete
# Check if the record have +id+ attribute
#
# @since 0.0.1
#
# @return [true, false] Whether or not this object has an id.
#
# @note +true+ doesn't mean that record exists in the database
#
# @see Couchbase::Model#exists?
def new?
!@id
end
# @return [true, false] Where on on this object persisted in the storage
def persisted?
!!@id
end
# Check if the key exists in the bucket
#
# @since 0.0.1
#
# @param [String, Symbol] id the record identifier
# @return [true, false] Whether or not the object with given +id+
# presented in the bucket.
def self.exists?(id)
!!bucket.get(id, :quiet => true)
end
# Check if this model exists in the bucket.
#
# @since 0.0.1
#
# @return [true, false] Whether or not this object presented in the
# bucket.
def exists?
model.exists?(@id)
end
# All defined attributes within a class.
#
# @since 0.0.1
#
# @see Model.attribute
#
# @return [Hash]
def self.attributes
@attributes ||= if self == Model
@@attributes.dup
else
couchbase_ancestor.attributes.dup
end
end
# All defined views within a class.
#
# @since 0.1.0
#
# @see Model.view
#
# @return [Array]
def self.views
@views ||= if self == Model
@@views.dup
else
couchbase_ancestor.views.dup
end
end
# Returns the first ancestor that is also a Couchbase::Model ancestor.
#
# @return Class
def self.couchbase_ancestor
ancestors[1..-1].each do |ancestor|
return ancestor if ancestor.ancestors.include?(Couchbase::Model)
end
end
# All the attributes of the current instance
#
# @since 0.0.1
#
# @return [Hash]
def attributes
@_attributes || {}
end
# Update all attributes without persisting the changes.
#
# @since 0.0.1
#
# @param [Hash] attrs attribute-value pairs.
def assign_attributes(attrs)
if id = attrs.delete(:id)
@id = id
end
attrs.each do |key, value|
setter = :"#{key}="
send(setter, value) if respond_to?(setter)
end
end
# Reload all the model attributes from the bucket
#
# @since 0.0.1
#
# @return [Model] the latest model state
#
# @raise [Error::MissingId] for records without +id+
# attribute
def reload
raise Couchbase::Error::MissingId, 'missing id attribute' unless @id
pristine = model.find(@id)
assign_attributes(pristine.attributes)
@meta[:cas] = pristine.meta[:cas]
self
end
# Format the model for use in a JSON response
#
# @since 0.5.2
#
# @return [Hash] a JSON representation of the model for REST APIs
#
def as_json(options = {})
attributes.merge({:id => @id}).as_json(options)
end
# @private The thread local storage for model specific stuff
#
# @since 0.0.1
def self.thread_storage
Couchbase.thread_storage[self] ||= {:uuid_algorithm => :sequential}
end
# @private Fetch the current connection
#
# @since 0.0.1
def self.bucket
self.thread_storage[:bucket] ||= Couchbase.bucket
end
# @private Set the current connection
#
# @since 0.0.1
#
# @param [Bucket] connection the connection instance
def self.bucket=(connection)
self.thread_storage[:bucket] = connection
end
# @private Get model class
#
# @since 0.0.1
def model
self.class
end
# @private Wrap the hash to the model class.
#
# @since 0.0.1
#
# @param [Bucket] bucket the reference to Bucket instance
# @param [Hash] data the Hash fetched by View, it should have at least
# +"id"+, +"key"+ and +"value"+ keys, also it could have optional
# +"doc"+ key.
#
# @return [Model]
def self.wrap(bucket, data)
doc = {
:id => data['id'],
:key => data['key'],
:value => data['value']
}
if data['doc']
doc[:meta] = data['doc']['meta']
doc[:doc] = data['doc']['value'] || data['doc']['json']
end
new(doc)
end
# @private Returns a string containing a human-readable representation
# of the record.
#
# @since 0.0.1
def inspect
attrs = []
attrs << ['key', @key.inspect] unless @key.nil?
attrs << ['value', @value.inspect] unless @value.nil?
model.attributes.map do |attr, default|
val = read_attribute(attr)
attrs << [attr.to_s, val.inspect] unless val.nil?
end
attrs.sort!
attrs.unshift([:id, id]) unless new?
sprintf('#<%s %s>', model, attrs.map { |a| a.join(': ') }.join(', '))
end
def self.inspect
buf = "#{name}"
if self != Couchbase::Model
buf << "(#{['id', attributes.map(&:first)].flatten.join(', ')})"
end
buf
end
# @private Returns a hash with model attributes
#
# @since 0.1.0
def attributes_with_values
ret = {:type => model.design_document}
model.attributes.keys.each do |attr|
ret[attr] = read_attribute(attr)
end
ret
end
private :attributes_with_values
# @private Returns if validations can be and should be performed,
# and they fail
#
# @since 0.5.5
def failed_validations?(options)
options[:validate] != false && respond_to?(:valid?) && !valid?
end
# Redefine (if exists) #to_key to use #key if #id is missing
def to_key
keys = [id || key]
keys.empty? ? nil : keys
end
def to_param
keys = to_key
if keys && !keys.empty?
keys.join('-')
end
end
include Couchbase::ActiveModel
ActiveSupport.run_load_hooks :couchbase_model, self
end
end