lib/aws/record/abstract_base.rb



# Copyright 2011-2012 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
#     http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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 'uuidtools'
require 'set'

require 'aws/record/scope'
require 'aws/record/naming'
require 'aws/record/validations'
require 'aws/record/dirty_tracking'
require 'aws/record/conversion'
require 'aws/record/errors'
require 'aws/record/exceptions'

module AWS
  module Record
    module AbstractBase
    
      def self.extended base

        base.send(:extend, ClassMethods)
        base.send(:include, InstanceMethods)
        base.send(:include, DirtyTracking)
        base.send(:extend, Validations)

        # these 3 modules are for rails 3+ active model compatability
        base.send(:extend, Naming)
        base.send(:include, Naming)
        base.send(:include, Conversion)

      end

      module InstanceMethods

        # Constructs a new record.
        #
        # @param [Hash] attributes Attributes that should be bulk assigned
        #   to this record.  You can also specify the shard (i.e. domain 
        #   or table) this record should persist to via +:shard+).
        #
        # @option attributes [String] :shard The domain/table this record
        #   should persist to.  If this is omitted, it will persist to the
        #   class default shard (which defaults to the class name).
        #
        # @return [Model,HashModel] Returns a new (non-persisted) record.  
        #   Call {#save} to persist changes to AWS.
        #
        def initialize attributes = {}
  
          attributes = attributes.dup
  
          # supporting :domain for backwards compatability, :shard is prefered
          @_shard = attributes.delete(:domain)
          @_shard ||= attributes.delete('domain')
          @_shard ||= attributes.delete(:shard)
          @_shard ||= attributes.delete('shard')
          @_shard = self.class.shard_name(@_shard)
  
          @_data = {}
          assign_default_values
          bulk_assign(attributes)
  
        end
  
        # @return [String] Returns the name of the shard this record
        #   is persisted to or will be persisted to.  Defaults to the 
        #   domain/table named after this record class.
        def shard
          @_shard
        end
        alias_method :domain, :shard # for backwards compatability
  
        # The id for each record is auto-generated.  The default strategy 
        # generates uuid strings.
        # @return [String] Returns the id string (uuid) for this record.  Retuns
        #   nil if this is a new record that has not been persisted yet.
        def id
          @_id
        end
  
        # @return [Hash] A hash with attribute names as hash keys (strings) and 
        #   attribute values (of mixed types) as hash values.
        def attributes
          attributes = Core::IndifferentHash.new
          attributes['id'] = id if persisted?
          self.class.attributes.keys.inject(attributes) do |hash,attr_name|
            hash.merge(attr_name => __send__(attr_name))
          end
        end
  
        # Acts like {#update} but does not call {#save}.
        #
        #   record.attributes = { :name => 'abc', :age => 20 }
        #
        # @param [Hash] attributes A hash of attributes to set on this record
        #   without calling save. 
        #
        # @return [Hash] Returns the attribute hash that was passed in.
        #
        def attributes= attributes
          bulk_assign(attributes)
        end
  
        # Persistence indicates if the record has been saved previously or not.
        #
        # @example
        #   @recipe = Recipe.new(:name => 'Buttermilk Pancackes')
        #   @recipe.persisted? #=> false
        #   @recipe.save!
        #   @recipe.persisted? #=> true
        #
        # @return [Boolean] Returns true if this record has been persisted.
        def persisted?
          !!@_persisted
        end
  
        # @return [Boolean] Returns true if this record has not been persisted
        #   to SimpleDB.
        def new_record?
          !persisted?
        end
  
        # @return [Boolean] Returns true if this record has no validation errors.
        def valid?
          run_validations
          errors.empty?
        end
  
        def errors
          @errors ||= Errors.new
        end
  
        # Creates new records, updates existing records.
        # @return [Boolean] Returns true if the record saved without errors,
        #   false otherwise.
        def save
          if valid?
            persisted? ? update : create
            clear_changes!
            true
          else
            false
          end
        end
  
        # Creates new records, updates exsting records.  If there is a validation
        # error then an exception is raised.
        # @raise [InvalidRecordError] Raised when the record has validation 
        #   errors and can not be saved.
        # @return [true] Returns true after a successful save.
        def save!
          raise InvalidRecordError.new(self) unless save
          true
        end
  
        # Bulk assigns the attributes and then saves the record.
        # @param [Hash] attribute_hash A hash of attribute names (keys) and
        #   attribute values to assign to this record.
        # @return (see #save)
        def update_attributes attribute_hash
          bulk_assign(attribute_hash)
          save
        end
  
        # Bulk assigns the attributes and then saves the record.  Raises
        # an exception (AWS::Record::InvalidRecordError) if the record is not 
        # valid.
        # @param (see #update_attributes)
        # @return [true]
        def update_attributes! attribute_hash
          if update_attributes(attribute_hash)
            true
          else
            raise InvalidRecordError.new(self)
          end
        end
  
        # Deletes the record.
        # @return [true]
        def delete
          if persisted?
            if deleted?
              raise 'unable to delete, this object has already been deleted'
            else
              delete_storage
              @_deleted = true
            end
          else
            raise 'unable to delete, this object has not been saved yet'
          end
        end
        alias_method :destroy, :delete
  
        # @return [Boolean] Returns true if this instance object has been deleted.
        def deleted?
          persisted? ? !!@_deleted : false
        end
  
        # If you define a custom setter, you use #[]= to set the value 
        # on the record.
        #
        #   class Book < AWS::Record::Model
        #
        #     string_attr :name
        #
        #     # replace the default #author= method
        #     def author= name
        #       self['author'] = name.blank? ? 'Anonymous' : name
        #     end
        #
        #   end
        #
        # @param [String,Symbol] The attribute name to set a value for
        # @param attribute_value The value to assign.
        protected
        def []= attribute_name, new_value
          self.class.attribute_for(attribute_name) do |attribute|
  
            if_tracking_changes do 
              original_value = type_cast(attribute, attribute_was(attribute.name))
              incoming_value = type_cast(attribute, new_value)
              if original_value == incoming_value
                clear_change!(attribute.name)
              else
                attribute_will_change!(attribute.name)
              end
            end
  
            @_data[attribute.name] = new_value
  
          end
        end
  
        # Returns the typecasted value for the named attribute.
        #
        #   book = Book.new(:title => 'My Book')
        #   book['title'] #=> 'My Book'
        #   book.title    #=> 'My Book'
        #
        # === Intended Use
        #
        # This method's primary use is for getting/setting the value for
        # an attribute inside a custom method:
        #
        #   class Book < AWS::Record::Model
        #
        #     string_attr :title
        #
        #     def title
        #       self['title'] ? self['title'].upcase : nil
        #     end
        #
        #   end
        #
        #   book = Book.new(:title => 'My Book')
        #   book.title    #=> 'MY BOOK'
        #
        # @param [String,Symbol] attribute_name The name of the attribute to fetch
        #   a value for.
        # @return The current type-casted value for the named attribute.
        protected
        def [] attribute_name
          self.class.attribute_for(attribute_name) do |attribute|
            type_cast(attribute, @_data[attribute.name])
          end
        end
  
        protected
        def create
          populate_id
          touch_timestamps('created_at', 'updated_at')
          increment_optimistic_lock_value
          create_storage
          @_persisted = true
        end

        private
        def update
          return unless changed?
          touch_timestamps('updated_at')
          increment_optimistic_lock_value
          update_storage
        end
  
        protected
        def populate_id
          @_id = UUIDTools::UUID.random_create.to_s
        end
  
        protected
        def touch_timestamps *attributes
          now = Time.now
          attributes.each do |attr_name|
            if 
              self.class.attributes[attr_name] and 
              !attribute_changed?(attr_name) 
              # don't touch timestamps the user modified
            then
              __send__("#{attr_name}=", now)
            end
          end
        end
  
        protected
        def increment_optimistic_lock_value
          if_locks_optimistically do |lock_attr|
            if value = self[lock_attr.name]
              self[lock_attr.name] = value + 1
            else
              self[lock_attr.name] = 1
            end
          end
        end
  
        protected
        def if_locks_optimistically &block
          if opt_lock_attr = self.class.optimistic_locking_attr
            yield(opt_lock_attr)
          end
        end
  
        protected
        def opt_lock_conditions
          conditions = {}
          if_locks_optimistically do |lock_attr|
            if was = attribute_was(lock_attr.name)
              conditions[:if] = { lock_attr.name => lock_attr.serialize(was) }
            else
              conditions[:unless_exists] = lock_attr.name
            end
          end
          conditions
        end
  
        private
        def assign_default_values
          # populate default attribute values
          ignore_changes do
            self.class.attributes.values.each do |attribute|
              begin
                # copy default values down so methods like #gsub! don't 
                # modify the default values for other objects
                @_data[attribute.name] = attribute.default_value.clone
              rescue TypeError
                @_data[attribute.name] = attribute.default_value
              end
            end
          end
        end
  
        private
        def bulk_assign hash
          flatten_date_parts(hash).each_pair do |attr_name, attr_value|
            __send__("#{attr_name}=", attr_value)
          end
        end

        private
        # Rails date and time select helpers split date and time
        # attributes into multiple values for form submission.
        # These attributes get named things like 'created_at(1i)'
        # and represent year/month/day/hour/min/sec parts of
        # the date/time.
        #
        # This method converts these attributes back into a single
        # value and converts them to Date and DateTime objects.
        def flatten_date_parts attributes
    
          multi_attributes = Set.new

          hash = attributes.inject({}) do |hash,(key,value)|
            # collects attribuets like "created_at(1i)" into an array of parts
            if key =~ /\(/
              key, index = key.to_s.split(/\(|i\)/)
              hash[key] ||= []
              hash[key][index.to_i - 1] = value.to_i
              multi_attributes << key
            else
              hash[key] = value
            end
            hash
          end

          # convert multiattribute values into date/time objects
          multi_attributes.each do |key|

            values = hash[key]

            hash[key] = case values.size
            when 0 then nil
            when 2 
              now = Time.now
              Time.local(now.year, now.month, now.day, values[0], values[1], 0, 0)
            when 3 then Date.new(*values)
            else DateTime.new(*values)
            end

          end

          hash

        end
  
        private
        def type_cast attribute, raw
          if attribute.set?
            values = Record.as_array(raw).inject([]) do |values,value|
              values << attribute.type_cast(value)
              values
            end
            Set.new(values.compact)
          else
            attribute.type_cast(raw)
          end
        end

        private
        def serialize_attributes

          hash = {}
          self.class.attributes.each_pair do |attribute_name,attribute|
            value = serialize_attribute(attribute, @_data[attribute_name])
            unless [nil, []].include?(value)
              hash[attribute_name] = value
            end
          end

          # simple db does not support persisting items without attribute values
          raise EmptyRecordError.new(self) if hash.empty?

          hash

        end

        private
        def serialize_attribute attribute, raw_value
          type_casted_value = type_cast(attribute, raw_value)
          case type_casted_value
          when nil then nil
          when Set then type_casted_value.map{|v| attribute.serialize(v) }
          else attribute.serialize(type_casted_value)
          end
        end

        # @private
        protected
        def hydrate id, data
          
          # @todo need to do something about partial hyrdation of attributes

          @_id = id

          # New objects are populated with default values, but we don't
          # want these values to hang around when hydrating persisted values
          # (those values may have been blanked out before save).
          self.class.attributes.values.each do |attribute|
            @_data[attribute.name] = nil 
          end

          ignore_changes do
            bulk_assign(deserialize_item_data(data))
          end

          @_persisted = true

        end
  
        protected
        def create_storage
          raise NotImplementedError
        end
  
        protected
        def update_storage
          raise NotImplementedError
        end

        protected
        def delete_storage
          raise NotImplementedError
        end

      end

      module ClassMethods

        # Allows you to override the default shard name for this class.
        # The shard name defaults to the class name.
        # @param [String] name 
        def set_shard_name name
          @_shard_name = name
        end
        alias_method :set_domain_name, :set_shard_name
        alias_method :shard_name=, :set_shard_name

        # Returns the name of the shard this class will persist records
        # into by default.
        #
        # @param [String] name Defaults to the name of this class.
        # @return [String] Returns the full prefixed domain name for this class.
        def shard_name name = nil
          case name
          when nil
            @_shard_name || self.name
          when AWS::DynamoDB::Table
            name.name.gsub(/^#{Record::table_prefix}/, '')
          when AWS::SimpleDB::Domain
            name.name.gsub(/^#{Record::domain_prefix}/, '')
          else name
          end
        end
        alias_method :domain_name, :shard_name

        # Adds a scoped finder to this class.
        #
        #   class Book < AWS::Record::Model
        #     scope :top_10, order(:popularity, :desc).limit(10) 
        #   end
        #
        #   Book.top_10.to_a
        #   #=> [#<Book...>, #<Book...>]
        #
        #   Book.top_10.first
        #   #=> #<Book...>
        #
        # You can also provide a block that accepts params for the scoped
        # finder.  This block should return a scope.
        #
        #   class Book < AWS::Record::Model
        #     scope :by_author, lambda {|name| where(:author => name) }
        #   end
        # 
        #   # top 10 books by the author 'John Doe'
        #   Book.by_author('John Doe').top_10
        # 
        # @param [Symbol] name The name of the scope.  Scope names should be
        #   method-safe and should not conflict with any other class methods.
        #
        # @param [Scope] scope 
        #
        def scope name, scope = nil, &block

          method_definition = scope ? lambda { scope } : block

          extend(Module.new { define_method(name, &method_definition) })

        end

        # @private
        def new_scope
          self::Scope.new(self)
        end

        def optimistic_locking attribute_name = :version_id
          attribute = integer_attr(attribute_name)
          @optimistic_locking_attr = attribute
        end

        # @return [Boolean] Returns true if this class is configured to
        #   perform optimistic locking.
        def optimistic_locking?
          !!@optimistic_locking_attr
        end

         @private
        def optimistic_locking_attr
          @optimistic_locking_attr
        end

        # @return [Hash<String,Attribute>] Returns a hash of all of the
        #   configured attributes for this class.
        def attributes
          @attributes ||= {}
        end

        # @private
        def attribute_for attribute_name, &block
          unless attribute = attributes[attribute_name.to_s]
            raise UndefinedAttributeError.new(attribute_name.to_s)
          end
          block_given? ? yield(attribute) : attribute
        end

        # @private
        def add_attribute attribute

          attr_name = attribute.name

          attributes[attr_name] = attribute

          # setter
          define_method("#{attr_name}=") do |value|
            self[attr_name] = value
          end

          # getter
          define_method(attr_name) do
            self[attr_name]
          end

          # before type-cast getter
          define_method("#{attr_name}_before_type_cast") do
            @_data[attr_name]
          end

          ## dirty tracking methods

          define_method("#{attr_name}_changed?") do
            attribute_changed?(attr_name)
          end

          define_method("#{attr_name}_change") do
            attribute_change(attr_name)
          end

          define_method("#{attr_name}_was") do
            attribute_was(attr_name)
          end

          define_method("#{attr_name}_will_change!") do
            attribute_will_change!(attr_name)
          end

          define_method("reset_#{attr_name}!") do
            reset_attribute!(attr_name)
          end

          attribute

        end

      end
    end
  end
end